A partir de PHP 8, podremos usar atributos. El objetivo de estos atributos, también conocidos como anotaciones en muchos otros idiomas, es agregar metadatos a clases, métodos, variables y demás; de forma estructurada.

El concepto de atributos no es nuevo en absoluto, hemos estado usando docblocks para simular su comportamiento durante años. Sin embargo, con la adición de atributos, ahora tenemos un ciudadano de primera clase en el idioma para representar este tipo de metadatos, en lugar de tener que analizar manualmente los bloques de documentos.

Entonces, ¿cómo se ven? ¿Cómo hacemos atributos personalizados? ¿Hay alguna advertencia? Esas son las preguntas que serán respondidas en este post. ¡Vamos a sumergirnos!

Lo primero es lo primero, así es como se vería el atributo en la naturaleza:

use \Support\Attributes\ListensTo;

class ProductSubscriber
{
    #[ListensTo(ProductCreated::class)]
    public function onProductCreated(ProductCreated $event) { /* … */ }

    #[ListensTo(ProductDeleted::class)]
    public function onProductDeleted(ProductDeleted $event) { /* … */ }
}

Mostraré otros ejemplos más adelante en esta publicación, pero creo que el ejemplo de los suscriptores de eventos es bueno para explicar el uso de los atributos al principio.

También sí, lo sé, la sintaxis podría no ser lo que deseabas o esperabas. Es posible que haya preferido @, o @:, o docblocks o, … Sin embargo, está aquí para quedarse, por lo que es mejor que aprendamos a manejarlo. Lo único que vale la pena mencionar sobre la sintaxis es que se discutieron todas las opciones, y hay muy buenas razones por las que se eligió esta sintaxis. Puede leer toda la discusión sobre el RFC en la lista interna.

Dicho esto, concentrémonos en las cosas geniales: ¿cómo funcionaría este ListensTo bajo el capó?

En primer lugar, los atributos personalizados son clases simples, anotadas en sí mismas con el atributo #[Attribute]; este atributo base solía llamarse PhpAttribute en el RFC original, pero luego se cambió con otro RFC.

Así es como se vería:

#[Attribute]
class ListensTo
{
    public string $event;

    public function __construct(string $event)
    {
        $this->event = $event;
    }
}

Eso es todo, bastante simple, ¿verdad? Tenga en cuenta el objetivo de los atributos: están destinados a agregar metadatos a clases y métodos, nada más. No deberían, y no pueden, usarse para, por ejemplo, la validación de entrada de argumentos. En otras palabras: no tendría acceso a los parámetros pasados a un método dentro de sus atributos. Había un RFC anterior que permitía este comportamiento, pero este RFC específicamente simplificó las cosas.

Volviendo al ejemplo del suscriptor del evento: todavía necesitamos leer los metadatos y registrar a nuestros suscriptores en algún lugar. Viniendo de Laravel, usaría un proveedor de servicios como el lugar para hacer esto, pero no dude en encontrar otras soluciones.

Aquí está la configuración repetitiva aburrida, solo para proporcionar un poco de contexto:

class EventServiceProvider extends ServiceProvider
{
    //  En escenarios de la vida real,
    //  tendriamos que resolver todos los subscritores y cache
    //  en lugar de usar manualmente un arreglo
    private array $subscribers = [
        ProductSubscriber::class,
    ];

    public function register(): void
    {
        // el despachador de eventos es resuelto desde el contenedor
        $eventDispatcher = $this->app->make(EventDispatcher::class);

        foreach ($this->subscribers as $subscriber) {
            // resolveremos todos los listeners registrados
            //  en la clase subscriptora,
            //  y los agregamos al despachador.
            foreach (
                $this->resolveListeners($subscriber) 
                as [$event, $listener]
            ) {
                $eventDispatcher->listen($event, $listener);
            }       
        }       
    }
}

Ahora echemos un vistazo a resolveListeners, que es donde sucede la magia.

private function resolveListeners(string $subscriberClass): array
{
    $reflectionClass = new ReflectionClass($subscriberClass);

    $listeners = [];

    foreach ($reflectionClass->getMethods() as $method) {
        $attributes = $method->getAttributes(ListensTo::class);
        
        foreach ($attributes as $attribute) {
            $listener = $attribute->newInstance();
            
            $listeners[] = [
                // Este es el evento que esta configurado en el atributo
                $listener->event,
    
                // Este es el listener para dicho evento
                [$subscriberClass, $method->getName()],
            ];
        }
    }

    return $listeners;
}

Puede ver que es más fácil leer los metadatos de esta manera, en comparación con el análisis de cadenas docblock. Sin embargo, hay dos complejidades que vale la pena analizar.

Primero está la llamada $attribute->newInstance(). Este es en realidad el lugar donde se crea una instancia de nuestra clase de atributo personalizado. Tomará los parámetros enumerados en la definición de atributo en nuestra clase de suscriptor y los pasará al constructor.

Esto significa que, técnicamente, ni siquiera necesita construir el atributo personalizado. Puede llamar a $attribute->getArguments() directamente. Además, instanciar la clase significa que tiene la flexibilidad del constructor para analizar la entrada de la forma que desee. Considerándolo todo, diría que sería bueno instanciar siempre el atributo usando newInstance().

La segunda cosa que vale la pena mencionar es el uso de ReflectionMethod::getAttributes(), la función que devuelve todos los atributos de un método. Puede pasarle dos argumentos para filtrar su salida.

Sin embargo, para comprender este filtrado, primero hay una cosa más que debe saber sobre los atributos. Esto podría haber sido obvio para usted, pero quería mencionarlo muy rápido de todos modos: es posible agregar varios atributos al mismo método, clase, propiedad o constante.

Podrías, por ejemplo, hacer esto:

#[
    Route(Http::POST, '/products/create'),
    Autowire,
]
class ProductsCreateController
{
    public function __invoke() { /* … */ }
}

Con eso en mente, está claro por qué Reflection*::getAttributes() devuelve una matriz, así que veamos cómo se puede filtrar su salida.

Digamos que está analizando las rutas del controlador, solo está interesado en el atributo Ruta. Puede pasar fácilmente esa clase como un filtro:

$attributes = $reflectionClass->getAttributes(Route::class);

El segundo parámetro cambia cómo se realiza ese filtrado. Puede pasar ReflectionAttribute::IS_INSTANCEOF, que devolverá todos los atributos que implementan una interfaz determinada.

Por ejemplo, supongamos que está analizando definiciones de contenedores, que se basan en varios atributos, podría hacer algo como esto:

$attributes = $reflectionClass->getAttributes(
    ContainerAttribute::class, 
    ReflectionAttribute::IS_INSTANCEOF
);

Definiciones

Ahora que tiene una idea de cómo funcionan los atributos en la práctica, es hora de profundizar en la teoría, asegurándose de comprenderlos a fondo. En primer lugar, mencioné esto brevemente antes, los atributos se pueden agregar en varios lugares.

En clases, así como clases anónimas;

#[AtributoDeClase]
class MyClass { /* … */ }

$objeto = new #[AtributoDeObjeto] class () { /* … */ };

Propiedades y constantes;

#[AtributoDePropiedad]
public int $foo;

#[AtributoDeConstante]
public const BAR = 1;

Métodos y funciones;

#[AtributoDeMetodo]
public function haceCosas(): void { /* … */ }

#[AtributoDeFuncion]
function foo() { /* … */ }

Closures

function foo(#[AtributoDeArgumento] $bar) { /* … */ }

Pueden ser declaradas antes o después de docblocks;

/** @return void */
#[AtributoDeMetodo]
public function haceCosas(): void { /* … */ }

Y puede tomar ninguno, uno o varios argumentos, que son definidos por el constructor del atributo:

#[Listens(ProductCreatedEvent::class)]
#[Autowire]
#[Route(Http::POST, '/products/create')]

En cuanto a los parámetros permitidos que puede pasar a un atributo, ya ha visto que se permiten constantes de clase, ::class y tipos escalares. Sin embargo, hay un poco más que decir sobre esto: los atributos solo aceptan expresiones constantes como argumentos de entrada.

Esto significa que se permiten expresiones escalares, incluso cambios de bit, así como ::class, constantes, arreglos y desempaquetado de arreglos, expresiones booleanas y el operador coalescente nulo. En el código fuente se puede encontrar una lista de todo lo que está permitido como expresión constante.

#[AttributeWithScalarExpression(1 + 1)]
#[AttributeWithClassNameAndConstants(PDO::class, PHP_VERSION_ID)]
#[AttributeWithClassConstant(Http::POST)]
#[AttributeWithBitShift(4 >> 1, 4 << 1)]

Configuración De Atributos

De forma predeterminada, los atributos se pueden agregar en varios lugares, como se indica arriba. Sin embargo, es posible configurarlos para que solo se puedan usar en lugares específicos. Por ejemplo, podría hacer que ClassAttribute solo se pueda usar en clases y en ningún otro lugar. La aceptación de este comportamiento se realiza pasando una marca al atributo Attribute en la clase de atributo.

Se parece a esto:

#[Attribute(Attribute::TARGET_CLASS)]
class MiAtributo
{
}

Están disponibles las siguientes banderas:

Attribute::TARGET_CLASS
Attribute::TARGET_FUNCTION
Attribute::TARGET_METHOD
Attribute::TARGET_PROPERTY
Attribute::TARGET_CLASS_CONSTANT
Attribute::TARGET_PARAMETER
Attribute::TARGET_ALL

Estos son indicadores de máscara de bits, por lo que puede combinarlos mediante una operación OR binaria.

#[Attribute(Attribute::TARGET_METHOD|Attribute::TARGET_FUNCTION)]
class MiAtributo
{
}

Otro indicador de configuración tiene que ver con la repetibilidad. De forma predeterminada, el mismo atributo no se puede aplicar dos veces, a menos que esté específicamente marcado como repetible. Esto se hace de la misma manera que la configuración de destino, con un indicador de bit.

#[Attribute(Attribute::IS_REPEATABLE)]
class MiAtributo
{
}

Tenga en cuenta que todas estas banderas solo se validan cuando se llama a $attribute->newInstance(), no antes.

Atributos incorporados

Una vez que se aceptó el RFC base, surgieron nuevas oportunidades para agregar atributos integrados al núcleo. Uno de esos ejemplos es el atributo #[Obsoleto], y un ejemplo popular ha sido un atributo #[Jit].

Estoy seguro de que veremos más y más atributos integrados en el futuro.

Como nota final, para aquellos que se preocupan por los genéricos: la sintaxis no entrará en conflicto con ellos, si alguna vez se agregaran en PHP, ¡así que estamos seguros!

Categorized in: