HardТеория11 min

Продвинутая рефлексия

ReflectionAttribute, ReflectionEnum, построение роутера, валидатора и сериализатора

Этот раздел посвящён продвинутым возможностям Reflection API: работе с атрибутами, перечислениями, Fiber и построению реальных инструментов -- роутера, валидатора, сериализатора и ORM-маппера.

ReflectionFunction -- анализ функций и замыканий

<?php
declare(strict_types=1);

// Named function
function calculateTax(float $amount, float $rate = 0.20): float
{
    return $amount * $rate;
}

$ref = new ReflectionFunction('calculateTax');

echo $ref->getName();                   // calculateTax
echo $ref->getNumberOfParameters();     // 2
echo $ref->getNumberOfRequiredParameters(); // 1
echo $ref->getReturnType()->getName();  // float

// Analyzing closures
$multiply = fn(int $a, int $b): int => $a * $b;

$closureRef = new ReflectionFunction($multiply);
echo $closureRef->getName(); // {closure}

foreach ($closureRef->getParameters() as $param) {
    echo "\${$param->getName()}: {$param->getType()->getName()}" . PHP_EOL;
}
// $a: int
// $b: int

Анализ замыканий с привязкой

<?php
declare(strict_types=1);

$factor = 10;
$closure = function (int $x) use ($factor): int {
    return $x * $factor;
};

$ref = new ReflectionFunction($closure);

// Get closure's bound variables (use-variables)
$staticVars = $ref->getStaticVariables();
print_r($staticVars); // ['factor' => 10]

// Get closure's $this binding
$closureThis = $ref->getClosureThis();
var_dump($closureThis); // null (no $this binding)

// Get closure's scope class
$scopeClass = $ref->getClosureScopeClass();
var_dump($scopeClass); // null (not bound to a class)

ReflectionEnum -- анализ перечислений (PHP 8.1+)

<?php
declare(strict_types=1);

enum Status: string
{
    case Active = 'active';
    case Inactive = 'inactive';
    case Pending = 'pending';

    public function label(): string
    {
        return match ($this) {
            self::Active => 'Active',
            self::Inactive => 'Inactive',
            self::Pending => 'Pending Review',
        };
    }
}

$ref = new ReflectionEnum(Status::class);

// Basic checks
var_dump($ref->isEnum());    // true
var_dump($ref->isBacked());  // true (has string backing type)

// Backing type
$backingType = $ref->getBackingType();
echo $backingType->getName(); // string

// Get all cases
$cases = $ref->getCases();
foreach ($cases as $case) {
    echo sprintf(
        '%s = %s',
        $case->getName(),
        $case->getValue(), // Only for backed enums (ReflectionEnumBackedCase)
    ) . PHP_EOL;
}
// Active = active
// Inactive = inactive
// Pending = pending

// Get single case
$activeCase = $ref->getCase('Active');
echo $activeCase->getName();  // Active

if ($activeCase instanceof ReflectionEnumBackedCase) {
    echo $activeCase->getBackingValue(); // active
}

// Check if case exists
var_dump($ref->hasCase('Active'));   // true
var_dump($ref->hasCase('Deleted'));  // false

Unit enum (без backing type)

<?php
declare(strict_types=1);

enum Color
{
    case Red;
    case Green;
    case Blue;
}

$ref = new ReflectionEnum(Color::class);

var_dump($ref->isBacked());    // false
var_dump($ref->getBackingType()); // null

$case = $ref->getCase('Red');
// $case is ReflectionEnumUnitCase (not ReflectionEnumBackedCase)
echo $case->getName(); // Red
// $case->getBackingValue() would throw Error (no backing value)

Программное создание экземпляров enum

<?php
declare(strict_types=1);

enum Priority: int
{
    case Low = 1;
    case Medium = 5;
    case High = 10;
}

$ref = new ReflectionEnum(Priority::class);

// Get enum value through ReflectionEnumBackedCase
foreach ($ref->getCases() as $case) {
    if ($case instanceof ReflectionEnumBackedCase) {
        $enumInstance = $case->getValue(); // Returns actual enum instance
        echo sprintf(
            '%s (value: %d, label: %s)',
            $enumInstance->name,
            $enumInstance->value,
            get_class($enumInstance),
        ) . PHP_EOL;
    }
}

ReflectionAttribute -- атрибуты (PHP 8.0+)

Атрибуты -- нативные метаданные PHP. Рефлексия позволяет их читать.

<?php
declare(strict_types=1);

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

#[Attribute(Attribute::TARGET_METHOD)]
final class Middleware
{
    /** @param list<string> $middlewares */
    public function __construct(
        public readonly array $middlewares,
    ) {}
}

#[Route('/api/users', name: 'users')]
final class UserController
{
    #[Route('/api/users', method: 'GET', name: 'users.list')]
    #[Middleware(['auth', 'throttle'])]
    public function list(): array
    {
        return [];
    }

    #[Route('/api/users/{id}', method: 'GET', name: 'users.show')]
    public function show(int $id): array
    {
        return [];
    }
}

Чтение атрибутов через рефлексию

<?php
declare(strict_types=1);

$classRef = new ReflectionClass(UserController::class);

// Get attributes on class
$classAttributes = $classRef->getAttributes();

foreach ($classAttributes as $attr) {
    echo $attr->getName();       // Route
    echo $attr->getTarget();     // Attribute::TARGET_CLASS
    var_dump($attr->isRepeated()); // false (only one Route on class)

    // Get constructor arguments
    $args = $attr->getArguments();
    // [0 => '/api/users', 'name' => 'users']

    // Create instance of the attribute
    $instance = $attr->newInstance();
    echo $instance->path;   // /api/users
    echo $instance->name;   // users
    echo $instance->method; // GET (default)
}

// Get attributes on method
$methodRef = $classRef->getMethod('list');
$methodAttrs = $methodRef->getAttributes();

foreach ($methodAttrs as $attr) {
    $instance = $attr->newInstance();
    echo get_class($instance) . ': ';

    if ($instance instanceof Route) {
        echo "{$instance->method} {$instance->path}";
    } elseif ($instance instanceof Middleware) {
        echo implode(', ', $instance->middlewares);
    }

    echo PHP_EOL;
}
// Route: GET /api/users
// Middleware: auth, throttle

Фильтрация атрибутов по типу

<?php
declare(strict_types=1);

$methodRef = new ReflectionMethod(UserController::class, 'list');

// Get only Route attributes (filter by class name)
$routeAttrs = $methodRef->getAttributes(Route::class);

foreach ($routeAttrs as $attr) {
    $route = $attr->newInstance();
    echo "{$route->method} {$route->path}" . PHP_EOL;
}

// Filter with IS_INSTANCEOF (includes subclasses)
$routeAttrs = $methodRef->getAttributes(
    Route::class,
    ReflectionAttribute::IS_INSTANCEOF,
);

Построение роутера через атрибуты

<?php
declare(strict_types=1);

#[Attribute(Attribute::TARGET_METHOD)]
final class ApiRoute
{
    public function __construct(
        public readonly string $path,
        public readonly string $method = 'GET',
        public readonly ?string $name = null,
    ) {}
}

final readonly class RouteDefinition
{
    public function __construct(
        public string $path,
        public string $httpMethod,
        public string $controllerClass,
        public string $controllerMethod,
        public ?string $name = null,
    ) {}
}

final class RouteCollector
{
    /** @var list<RouteDefinition> */
    private array $routes = [];

    /**
     * Scan controller classes and collect routes from attributes.
     *
     * @param list<class-string> $controllers
     */
    public function collect(array $controllers): void
    {
        foreach ($controllers as $controllerClass) {
            $classRef = new ReflectionClass($controllerClass);

            foreach ($classRef->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
                $attributes = $method->getAttributes(ApiRoute::class);

                foreach ($attributes as $attr) {
                    $route = $attr->newInstance();

                    $this->routes[] = new RouteDefinition(
                        path: $route->path,
                        httpMethod: $route->method,
                        controllerClass: $controllerClass,
                        controllerMethod: $method->getName(),
                        name: $route->name,
                    );
                }
            }
        }
    }

    /**
     * Find matching route for a request.
     */
    public function match(string $httpMethod, string $path): ?RouteDefinition
    {
        foreach ($this->routes as $route) {
            if ($route->httpMethod !== $httpMethod) {
                continue;
            }

            // Simple pattern matching (convert {param} to regex)
            $pattern = preg_replace('/\{(\w+)\}/', '(?P<$1>[^/]+)', $route->path);
            $pattern = '#^' . $pattern . '$#';

            if (preg_match($pattern, $path)) {
                return $route;
            }
        }

        return null;
    }

    /** @return list<RouteDefinition> */
    public function getRoutes(): array
    {
        return $this->routes;
    }
}

// Example controller
final class OrderController
{
    #[ApiRoute('/api/orders', method: 'GET', name: 'orders.list')]
    public function list(): array
    {
        return ['orders' => []];
    }

    #[ApiRoute('/api/orders/{id}', method: 'GET', name: 'orders.show')]
    public function show(int $id): array
    {
        return ['order' => $id];
    }

    #[ApiRoute('/api/orders', method: 'POST', name: 'orders.create')]
    public function create(): array
    {
        return ['created' => true];
    }
}

// Usage
$collector = new RouteCollector();
$collector->collect([OrderController::class]);

$route = $collector->match('GET', '/api/orders/42');
if ($route !== null) {
    echo "{$route->controllerClass}::{$route->controllerMethod}" . PHP_EOL;
    // OrderController::show
}

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

<?php
declare(strict_types=1);

#[Attribute(Attribute::TARGET_PROPERTY)]
final class NotBlank
{
    public function __construct(
        public readonly string $message = 'This field cannot be blank',
    ) {}
}

#[Attribute(Attribute::TARGET_PROPERTY)]
final class Length
{
    public function __construct(
        public readonly ?int $min = null,
        public readonly ?int $max = null,
        public readonly string $message = 'Invalid length',
    ) {}
}

#[Attribute(Attribute::TARGET_PROPERTY)]
final class Email
{
    public function __construct(
        public readonly string $message = 'Invalid email address',
    ) {}
}

final readonly class ValidationError
{
    public function __construct(
        public string $property,
        public string $message,
    ) {}
}

final class Validator
{
    /**
     * Validate an object based on its property attributes.
     *
     * @return list<ValidationError>
     */
    public function validate(object $object): array
    {
        $errors = [];
        $ref = new ReflectionClass($object);

        foreach ($ref->getProperties() as $property) {
            $value = $property->getValue($object);

            foreach ($property->getAttributes() as $attr) {
                $constraint = $attr->newInstance();

                $error = match (true) {
                    $constraint instanceof NotBlank => $this->validateNotBlank(
                        $value,
                        $property->getName(),
                        $constraint,
                    ),
                    $constraint instanceof Length => $this->validateLength(
                        $value,
                        $property->getName(),
                        $constraint,
                    ),
                    $constraint instanceof Email => $this->validateEmail(
                        $value,
                        $property->getName(),
                        $constraint,
                    ),
                    default => null,
                };

                if ($error !== null) {
                    $errors[] = $error;
                }
            }
        }

        return $errors;
    }

    private function validateNotBlank(mixed $value, string $prop, NotBlank $constraint): ?ValidationError
    {
        if ($value === null || $value === '' || $value === []) {
            return new ValidationError($prop, $constraint->message);
        }

        return null;
    }

    private function validateLength(mixed $value, string $prop, Length $constraint): ?ValidationError
    {
        if (!is_string($value)) {
            return null;
        }

        $length = mb_strlen($value);

        if ($constraint->min !== null && $length < $constraint->min) {
            return new ValidationError($prop, "{$constraint->message}: minimum {$constraint->min}");
        }

        if ($constraint->max !== null && $length > $constraint->max) {
            return new ValidationError($prop, "{$constraint->message}: maximum {$constraint->max}");
        }

        return null;
    }

    private function validateEmail(mixed $value, string $prop, Email $constraint): ?ValidationError
    {
        if (!is_string($value) || !filter_var($value, FILTER_VALIDATE_EMAIL)) {
            return new ValidationError($prop, $constraint->message);
        }

        return null;
    }
}

// DTO with validation attributes
final class CreateUserRequest
{
    public function __construct(
        #[NotBlank]
        #[Length(min: 2, max: 50)]
        public readonly string $name,

        #[NotBlank]
        #[Email]
        public readonly string $email,

        #[Length(min: 8, max: 128)]
        public readonly string $password,
    ) {}
}

$request = new CreateUserRequest(
    name: 'A',
    email: 'not-an-email',
    password: '123',
);

$validator = new Validator();
$errors = $validator->validate($request);

foreach ($errors as $error) {
    echo "{$error->property}: {$error->message}" . PHP_EOL;
}
// name: Invalid length: minimum 2
// email: Invalid email address
// password: Invalid length: minimum 8

Построение сериализатора

<?php
declare(strict_types=1);

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

#[Attribute(Attribute::TARGET_PROPERTY)]
final class Ignore
{
}

final class Serializer
{
    /**
     * Serialize object to associative array.
     *
     * @return array<string, mixed>
     */
    public function serialize(object $object): array
    {
        $ref = new ReflectionClass($object);
        $data = [];

        foreach ($ref->getProperties() as $property) {
            // Skip properties with #[Ignore]
            if ($property->getAttributes(Ignore::class) !== []) {
                continue;
            }

            // Check for custom name
            $nameAttrs = $property->getAttributes(SerializedName::class);
            $key = $nameAttrs !== []
                ? $nameAttrs[0]->newInstance()->name
                : $this->camelToSnake($property->getName());

            $value = $property->getValue($object);

            // Recursively serialize nested objects
            $data[$key] = match (true) {
                is_object($value) && !$value instanceof \DateTimeInterface && !$value instanceof \BackedEnum
                    => $this->serialize($value),
                $value instanceof \DateTimeInterface
                    => $value->format('c'),
                $value instanceof \BackedEnum
                    => $value->value,
                is_array($value)
                    => array_map(
                        fn($item) => is_object($item) ? $this->serialize($item) : $item,
                        $value,
                    ),
                default => $value,
            };
        }

        return $data;
    }

    private function camelToSnake(string $input): string
    {
        return strtolower(preg_replace('/[A-Z]/', '_$0', lcfirst($input)));
    }
}

// Usage
final class UserResponse
{
    public function __construct(
        public readonly int $id,
        #[SerializedName('full_name')]
        public readonly string $firstName,
        public readonly string $email,
        #[Ignore]
        public readonly string $passwordHash,
        public readonly \DateTimeImmutable $createdAt,
    ) {}
}

$user = new UserResponse(
    id: 1,
    firstName: 'Alice',
    email: '[email protected]',
    passwordHash: '$2y$10$...',
    createdAt: new \DateTimeImmutable('2025-01-15 12:00:00'),
);

$serializer = new Serializer();
$data = $serializer->serialize($user);
print_r($data);
// [
//     'id' => 1,
//     'full_name' => 'Alice',
//     'email' => '[email protected]',
//     'created_at' => '2025-01-15T12:00:00+00:00',
// ]
// Note: passwordHash is excluded (has #[Ignore])

ReflectionClassConstant -- типизированные константы (PHP 8.3+)

<?php
declare(strict_types=1);

class Config
{
    public const string APP_NAME = 'MyApp';
    public const int MAX_RETRIES = 3;
    public const float TIMEOUT = 30.5;
    protected const array ALLOWED_HOSTS = ['localhost', '127.0.0.1'];
}

$ref = new ReflectionClass(Config::class);

foreach ($ref->getReflectionConstants() as $const) {
    echo sprintf(
        '%s %s: %s = %s',
        $const->isPublic() ? 'public' : ($const->isProtected() ? 'protected' : 'private'),
        $const->hasType() ? $const->getType()->getName() : 'untyped',
        $const->getName(),
        var_export($const->getValue(), true),
    ) . PHP_EOL;
}
// public string: APP_NAME = 'MyApp'
// public int: MAX_RETRIES = 3
// public float: TIMEOUT = 30.5
// protected array: ALLOWED_HOSTS = array('localhost', '127.0.0.1')

ReflectionFiber -- анализ Fiber (PHP 8.1+)

<?php
declare(strict_types=1);

$fiber = new Fiber(function (): void {
    $value = Fiber::suspend('first suspend');
    echo "Resumed with: {$value}" . PHP_EOL;
    Fiber::suspend('second suspend');
});

$refFiber = new ReflectionFiber($fiber);

// Before start
// echo $refFiber->getExecutingFile();  // Error: fiber not started

$result = $fiber->start();
echo $result . PHP_EOL; // first suspend

// After start, during suspend
$refFiber = new ReflectionFiber($fiber);
echo $refFiber->getExecutingFile() . PHP_EOL; // /path/to/file.php
echo $refFiber->getExecutingLine() . PHP_EOL; // line number of Fiber::suspend()

// Get backtrace
$trace = $refFiber->getTrace();
print_r($trace); // Stack trace at point of suspension

// Get the actual Fiber
$actualFiber = $refFiber->getFiber();
var_dump($actualFiber === $fiber); // true

Тестирование с рефлексией

Рефлексия часто используется для тестирования приватных методов и свойств.

<?php
declare(strict_types=1);

final class PaymentProcessor
{
    private int $retryCount = 0;
    private const int MAX_RETRIES = 3;

    public function process(float $amount): bool
    {
        return $this->attemptPayment($amount);
    }

    private function attemptPayment(float $amount): bool
    {
        // Complex payment logic
        $this->retryCount++;

        if ($amount <= 0) {
            return false;
        }

        return $this->retryCount <= self::MAX_RETRIES;
    }

    private function calculateFee(float $amount): float
    {
        return round($amount * 0.029 + 0.30, 2);
    }
}

// In PHPUnit test:
final class PaymentProcessorTest // extends TestCase
{
    /**
     * Helper to invoke private methods.
     */
    private function invokePrivateMethod(
        object $object,
        string $methodName,
        mixed ...$args,
    ): mixed {
        $method = new ReflectionMethod($object, $methodName);

        return $method->invoke($object, ...$args);
    }

    /**
     * Helper to get private property value.
     */
    private function getPrivateProperty(object $object, string $propertyName): mixed
    {
        $property = new ReflectionProperty($object, $propertyName);

        return $property->getValue($object);
    }

    /**
     * Helper to set private property value.
     */
    private function setPrivateProperty(
        object $object,
        string $propertyName,
        mixed $value,
    ): void {
        $property = new ReflectionProperty($object, $propertyName);
        $property->setValue($object, $value);
    }

    public function testCalculateFee(): void
    {
        $processor = new PaymentProcessor();

        $fee = $this->invokePrivateMethod($processor, 'calculateFee', 100.00);
        assert($fee === 3.20); // 100 * 0.029 + 0.30 = 3.20
    }

    public function testRetryCountIncrement(): void
    {
        $processor = new PaymentProcessor();
        $processor->process(50.00);

        $retryCount = $this->getPrivateProperty($processor, 'retryCount');
        assert($retryCount === 1);
    }

    public function testMaxRetriesExceeded(): void
    {
        $processor = new PaymentProcessor();

        // Set retry count close to max
        $this->setPrivateProperty($processor, 'retryCount', 3);

        $result = $processor->process(50.00);
        assert($result === false); // retryCount (4) > MAX_RETRIES (3)
    }
}

Замечание: Тестирование приватных методов -- спорная практика. Многие считают, что тестировать нужно только публичный API. Если вам часто приходится тестировать приватные методы, возможно, стоит вынести логику в отдельный класс.

Производительность: кэширование рефлексии

<?php
declare(strict_types=1);

final class ReflectionCache
{
    /** @var array<class-string, ReflectionClass> */
    private static array $classes = [];

    /** @var array<string, list<ReflectionAttribute>> */
    private static array $attributes = [];

    /** @var array<class-string, list<ReflectionProperty>> */
    private static array $properties = [];

    /**
     * @template T of object
     * @param class-string<T> $className
     * @return ReflectionClass<T>
     */
    public static function getClass(string $className): ReflectionClass
    {
        return self::$classes[$className] ??= new ReflectionClass($className);
    }

    /**
     * @return list<ReflectionProperty>
     */
    public static function getProperties(string $className): array
    {
        return self::$properties[$className] ??= self::getClass($className)->getProperties();
    }

    /**
     * Get attributes of a specific type from a property.
     *
     * @template T of object
     * @param class-string<T> $attributeClass
     * @return list<ReflectionAttribute<T>>
     */
    public static function getPropertyAttributes(
        string $className,
        string $propertyName,
        string $attributeClass,
    ): array {
        $key = "{$className}::{$propertyName}@{$attributeClass}";

        if (!isset(self::$attributes[$key])) {
            $property = self::getClass($className)->getProperty($propertyName);
            self::$attributes[$key] = $property->getAttributes($attributeClass);
        }

        return self::$attributes[$key];
    }

    public static function clear(): void
    {
        self::$classes = [];
        self::$attributes = [];
        self::$properties = [];
    }
}

// Usage — repeated reflection calls use cache
$ref = ReflectionCache::getClass(User::class); // Creates ReflectionClass once
$ref2 = ReflectionCache::getClass(User::class); // Returns cached instance
var_dump($ref === $ref2); // true

Запомни: ReflectionAttribute::newInstance() создаёт новый экземпляр атрибута при каждом вызове -- обязательно кэшируйте результаты. ReflectionEnum доступен с PHP 8.1 и позволяет анализировать перечисления: получать case'ы, backing type и значения. Для сложных систем на атрибутах (роутинг, валидация, ORM) всегда используйте кэш рефлексии -- это может ускорить работу в 10-100 раз.


Проверь себя

5 из 11

Как `ReflectionClass::getConstants()` и `ReflectionClass::getReflectionConstants()` отличаются?

Почему тестирование приватных методов через рефлексию считается спорной практикой?

Какой метод `ReflectionClassConstant` доступен в PHP 8.3 для проверки наличия типа у константы?

Что произойдёт при вызове `ReflectionEnumBackedCase::getBackingValue()` на case unit enum?

Что возвращает `ReflectionEnum::isBacked()` для `enum Color { case Red; case Green; }`?