24 Attribute (Annotations)

Attribute, auch bekannt als Annotations in anderen Programmiersprachen, wurden mit PHP 8.0 eingeführt und stellen eine bedeutende Erweiterung der Metaprogrammierung in PHP dar. Sie ermöglichen es, Klassen, Methoden, Funktionen, Eigenschaften, Parameter und andere Code-Elemente mit strukturierten Metadaten zu versehen, die zur Laufzeit über Reflection ausgelesen werden können.

24.1 Grundkonzept und Syntax

Attribute werden in PHP mit der doppelten Klammernotation (<< und >>) eingeführt:

<<AttributeName>>
class MeineKlasse
{
    <<PropertyAttribute>>
    public string $eigenschaft;
    
    <<MethodAttribute("parameter", wert: true)>>
    public function meineMethode(
        <<ParameterAttribute>> $parameter
    ): void {
        // Methodenimplementierung
    }
}

Die Syntax unterscheidet sich bewusst von der zuvor in PHPDoc verwendeten Annotation-Syntax (@Annotation), um einen klaren Unterschied zwischen dokumentationsbasierten Annotationen und zur Laufzeit auslesbaren Attributen zu schaffen.

24.2 Attribute vs. Docblock-Annotationen

Vor PHP 8.0 wurden Metainformationen primär über PHPDoc-Blöcke bereitgestellt:

/**
 * @Entity
 * @Table(name="users")
 */
class User
{
    /**
     * @Id
     * @Column(type="integer")
     * @GeneratedValue
     */
    private $id;
}

Docblock-Annotationen haben jedoch mehrere Nachteile: - Sie sind nur Kommentare und werden nicht vom PHP-Parser validiert - Sie benötigen eine Parsing-Bibliothek zur Laufzeit - Es gibt keine standardisierte Syntax oder Validierung - Es können Fehler auftreten, wenn Docblocks während der Minimierung entfernt werden

Attribute beheben diese Probleme:

<<Entity>>
<<Table(name: "users")>>
class User
{
    <<Id>>
    <<Column(type: "integer")>>
    <<GeneratedValue>>
    private int $id;
}

24.3 Erstellung eigener Attribute

Um ein eigenes Attribut zu erstellen, definieren Sie eine Klasse und versehen diese mit dem Meta-Attribut <<Attribute>>:

<<Attribute>>
class Route
{
    public function __construct(
        public string $path,
        public string $name = '',
        public array $methods = ['GET'],
        public array $requirements = []
    ) {}
}

Das Meta-Attribut Attribute selbst kann Parameter erhalten, um das Verhalten des benutzerdefinierten Attributs zu steuern:

<<Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_FUNCTION)>>
class Route
{
    // ...
}

24.4 Ziele von Attributen

Sie können einschränken, auf welche Code-Elemente ein Attribut angewendet werden kann, indem Sie die TARGET_*-Konstanten im Meta-Attribut verwenden:

<<Attribute(Attribute::TARGET_CLASS)>>
class Entity {}

<<Attribute(Attribute::TARGET_PROPERTY)>>
class Column {}

<<Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_FUNCTION)>>
class Cached {}

<<Attribute(Attribute::TARGET_ALL)>>  // Standard, wenn nicht angegeben
class Debug {}

Die verfügbaren Ziel-Konstanten sind: - TARGET_CLASS - TARGET_FUNCTION - TARGET_METHOD - TARGET_PROPERTY - TARGET_CLASS_CONSTANT - TARGET_PARAMETER - TARGET_ALL (Kombination aller obigen)

24.5 Wiederholbarkeit von Attributen

Standardmäßig kann ein Attribut nur einmal auf ein Code-Element angewendet werden. Mit dem Flag IS_REPEATABLE können Sie ein Attribut so konfigurieren, dass es mehrfach angewendet werden kann:

<<Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)>>
class Route
{
    public function __construct(
        public string $path,
        public array $methods = ['GET']
    ) {}
}

<<Route(path: "/users", methods: ["GET", "POST"])>>
<<Route(path: "/users/{id}", methods: ["GET"])>>
class UserController
{
    // ...
}

24.6 Auslesen von Attributen zur Laufzeit

Attribute können zur Laufzeit über die Reflection-API ausgelesen werden:

function parseRoutes(string $controllerClass): array
{
    $routes = [];
    $reflectionClass = new ReflectionClass($controllerClass);
    
    // Lese Klassenattribute
    $attributes = $reflectionClass->getAttributes(Route::class);
    foreach ($attributes as $attribute) {
        // Instanziiere das Attributobjekt
        $route = $attribute->newInstance();
        $routes[] = [
            'path' => $route->path,
            'methods' => $route->methods,
            'controller' => $controllerClass,
            'action' => null
        ];
    }
    
    // Lese Methodenattribute
    foreach ($reflectionClass->getMethods() as $method) {
        $attributes = $method->getAttributes(Route::class);
        foreach ($attributes as $attribute) {
            $route = $attribute->newInstance();
            $routes[] = [
                'path' => $route->path,
                'methods' => $route->methods,
                'controller' => $controllerClass,
                'action' => $method->getName()
            ];
        }
    }
    
    return $routes;
}

Mit getAttributes() können Sie optional Attribute nach Namen filtern. Wenn Sie keinen Klassennamen angeben, werden alle Attribute zurückgegeben.

24.7 Typ-Validierung in Attributkonstruktoren

Da Attribute echte PHP-Klassen sind, können Sie alle Typisierungsfeatures von PHP nutzen, um die übergebenen Parameter zu validieren:

<<Attribute>>
class ValidRange
{
    public function __construct(
        public int|float $min,
        public int|float $max
    ) {
        if ($min > $max) {
            throw new InvalidArgumentException('Min cannot be greater than max');
        }
    }
}

class Product
{
    <<ValidRange(0, 1000)>>
    public float $price;
}

24.8 Praxisbeispiele für Attribute

24.8.1 1. Ein einfaches Routing-System

<<Attribute(Attribute::TARGET_METHOD)>>
class Route
{
    public function __construct(
        public string $path,
        public array $methods = ['GET']
    ) {}
}

class UserController
{
    <<Route("/users", methods: ["GET"])>>
    public function index(): void
    {
        // Zeige Benutzerliste
    }
    
    <<Route("/users/{id}", methods: ["GET"])>>
    public function show(int $id): void
    {
        // Zeige Benutzer mit der ID $id
    }
    
    <<Route("/users", methods: ["POST"])>>
    public function store(): void
    {
        // Erstelle neuen Benutzer
    }
}

// Router-Implementierung
class Router
{
    private array $routes = [];
    
    public function registerController(string $controllerClass): void
    {
        $reflectionClass = new ReflectionClass($controllerClass);
        
        foreach ($reflectionClass->getMethods() as $method) {
            $attributes = $method->getAttributes(Route::class);
            foreach ($attributes as $attribute) {
                $route = $attribute->newInstance();
                $this->routes[] = [
                    'path' => $route->path,
                    'methods' => $route->methods,
                    'controller' => $controllerClass,
                    'action' => $method->getName()
                ];
            }
        }
    }
    
    public function dispatch(string $path, string $method): mixed
    {
        foreach ($this->routes as $route) {
            // Vereinfachte Pfadüberprüfung (in der Praxis würde man Regex verwenden)
            if ($route['path'] === $path && in_array($method, $route['methods'])) {
                $controller = new $route['controller']();
                return $controller->{$route['action']}();
            }
        }
        
        throw new RuntimeException('Route not found');
    }
}

24.8.2 2. Validierungssystem

<<Attribute(Attribute::TARGET_PROPERTY)>>
class Required
{
}

<<Attribute(Attribute::TARGET_PROPERTY)>>
class Email
{
}

<<Attribute(Attribute::TARGET_PROPERTY)>>
class MinLength
{
    public function __construct(public int $length) {}
}

class UserRegistration
{
    <<Required>>
    <<MinLength(3)>>
    public string $username;
    
    <<Required>>
    <<Email>>
    public string $email;
    
    <<Required>>
    <<MinLength(8)>>
    public string $password;
}

class Validator
{
    public function validate(object $object): array
    {
        $errors = [];
        $reflection = new ReflectionClass($object);
        
        foreach ($reflection->getProperties() as $property) {
            $propertyName = $property->getName();
            $value = $property->getValue($object);
            
            // Prüfe Required-Attribut
            if ($property->getAttributes(Required::class) && empty($value)) {
                $errors[$propertyName][] = "$propertyName is required";
            }
            
            // Prüfe Email-Attribut
            if ($property->getAttributes(Email::class) && !empty($value) && !filter_var($value, FILTER_VALIDATE_EMAIL)) {
                $errors[$propertyName][] = "$propertyName must be a valid email";
            }
            
            // Prüfe MinLength-Attribut
            $minLengthAttrs = $property->getAttributes(MinLength::class);
            foreach ($minLengthAttrs as $attr) {
                $minLength = $attr->newInstance()->length;
                if (!empty($value) && strlen($value) < $minLength) {
                    $errors[$propertyName][] = "$propertyName must be at least $minLength characters";
                }
            }
        }
        
        return $errors;
    }
}

// Verwendung:
$registration = new UserRegistration();
$registration->username = "jo";  // Zu kurz
$registration->email = "not-an-email";  // Keine gültige E-Mail
$registration->password = "weak";  // Zu kurz

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

24.8.3 3. Dependency Injection Container

<<Attribute(Attribute::TARGET_PARAMETER)>>
class Inject
{
    public function __construct(public ?string $serviceName = null) {}
}

class Container
{
    private array $services = [];
    
    public function register(string $name, callable $factory): void
    {
        $this->services[$name] = $factory;
    }
    
    public function get(string $className): object
    {
        $reflection = new ReflectionClass($className);
        $constructor = $reflection->getConstructor();
        
        if (!$constructor) {
            return new $className();
        }
        
        $parameters = [];
        foreach ($constructor->getParameters() as $parameter) {
            $injectAttrs = $parameter->getAttributes(Inject::class);
            
            if (empty($injectAttrs)) {
                // Kein Inject-Attribut - versuche, nach Typ zu injizieren
                $type = $parameter->getType();
                if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) {
                    $parameters[] = $this->get($type->getName());
                } elseif ($parameter->isDefaultValueAvailable()) {
                    $parameters[] = $parameter->getDefaultValue();
                } else {
                    throw new RuntimeException("Cannot resolve parameter '{$parameter->getName()}'");
                }
            } else {
                // Inject-Attribut vorhanden
                $inject = $injectAttrs[0]->newInstance();
                $serviceName = $inject->serviceName ?? $parameter->getName();
                
                if (isset($this->services[$serviceName])) {
                    $parameters[] = ($this->services[$serviceName])();
                } elseif ($parameter->isDefaultValueAvailable()) {
                    $parameters[] = $parameter->getDefaultValue();
                } else {
                    throw new RuntimeException("Service '$serviceName' not found");
                }
            }
        }
        
        return new $className(...$parameters);
    }
}

interface LoggerInterface
{
    public function log(string $message): void;
}

class FileLogger implements LoggerInterface
{
    public function __construct(private string $logFile) {}
    
    public function log(string $message): void
    {
        file_put_contents($this->logFile, $message . PHP_EOL, FILE_APPEND);
    }
}

class UserService
{
    public function __construct(
        private UserRepository $repository,
        <<Inject("logger")>> private LoggerInterface $logger
    ) {}
}

// Verwendung:
$container = new Container();
$container->register('logger', fn() => new FileLogger('/var/log/app.log'));
$container->register(UserRepository::class, fn() => new UserRepository());

$userService = $container->get(UserService::class);

24.9 Best Practices und Empfehlungen

  1. Für was Attribute verwenden
  2. Für was Attribute NICHT verwenden
  3. Attribute sinnvoll benennen und strukturieren
  4. Attribute in Namespaces organisieren
  5. Performance beachten

24.10 Attribute in populären PHP-Frameworks

Viele moderne PHP-Frameworks haben bereits Attribute in ihre APIs integriert:

Symfony:

use Symfony\Component\Routing\Annotation\Route;

class ProductController
{
    <<Route("/product/{id}", name: "product_show")>>
    public function show(int $id): Response
    {
        // ...
    }
}

Laravel (ab Version 8.x mit externen Paketen):

use Spatie\RouteAttributes\Attributes\Get;

class UserController
{
    <<Get("/users/{id}")>>
    public function show(User $user)
    {
        // ...
    }
}

Doctrine:

use Doctrine\ORM\Mapping as ORM;

<<ORM\Entity>>
<<ORM\Table(name: "products")>>
class Product
{
    <<ORM\Id>>
    <<ORM\GeneratedValue>>
    <<ORM\Column(type: "integer")>>
    private int $id;
    
    <<ORM\Column(type: "string", length: 255)>>
    private string $name;
}

24.11 Einschränkungen und Herausforderungen

Trotz ihrer Mächtigkeit haben Attribute einige Einschränkungen:

  1. Kompatibilität: Attribute sind nur in PHP 8.0 und höher verfügbar, was die Kompatibilität mit älteren Projekten einschränkt.

  2. Metaprogrammierungskomplexität: Übermäßiger Einsatz von Attributen kann zu schwer verständlichem “magischem” Code führen.

  3. IDE-Unterstützung: Obwohl sich die IDE-Unterstützung verbessert, ist sie noch nicht so ausgereift wie für andere Sprachfeatures.

  4. Debugging: Fehler in Attributen können manchmal schwer zu debuggen sein, da sie zur Laufzeit ausgewertet werden.

  5. Leistungsaspekte: Das wiederholte Auslesen von Attributen über Reflection kann die Leistung beeinträchtigen, wenn kein geeignetes Caching implementiert ist.