PHP Attributes (introduced in PHP 8.0) provide a structured, native way to add metadata to classes, methods, properties, constants, and parameters. This metadata enables declarative configuration, behavior modification, and enhanced code documentation without cluttering your business logic.
The spiral/attributes component provides a powerful interface for reading and processing PHP attributes throughout
your application.
Declarative Configuration Define behavior through metadata rather than procedural configuration, making code self-documenting and reducing boilerplate.
Native PHP Support Use PHP 8.0+ native attribute syntax with full language-level support, IDE integration, and static analysis.
Reflection Integration Seamlessly work with PHP's reflection API to discover and process attributes at runtime.
Aspect-Oriented Programming Combine attributes with interceptors to implement cross-cutting concerns (logging, caching, validation) without modifying business logic.
ORM Entity Mapping
#[Entity(table: 'users')]
class User
{
#[Column(type: 'integer', primary: true)]
public int $id;
#[Column(type: 'string', length: 255)]
public string $email;
}
Route Definition
class UserController
{
#[Route(path: '/users', methods: ['GET'])]
public function list(): array
{
// Return user list
}
}
Validation Rules
class CreateUserRequest
{
#[NotEmpty, Email]
public string $email;
#[NotEmpty, MinLength(8)]
public string $password;
}
Event Listeners
class UserRegistrationHandler
{
#[Listener(event: UserRegistered::class)]
public function sendWelcomeEmail(UserRegistered $event): void
{
// Send email
}
}
Install the component via Composer:
composer require spiral/attributes
Register the AttributesBootloader to enable attribute reading throughout your application:
public function defineBootloaders(): array
{
return [
// ...
\Spiral\Bootloader\Attributes\AttributesBootloader::class,
// ...
];
}
Read more about bootloaders in the Framework — Bootloaders section.
The ReaderInterface provides methods to read metadata from any PHP reflection target:
use Spiral\Attributes\ReaderInterface;
class AttributeProcessor
{
public function __construct(
private readonly ReaderInterface $reader
) {}
public function processClass(\ReflectionClass $class): void
{
// Read all attributes from the class
foreach ($this->reader->getClassMetadata($class) as $attribute) {
// Process each attribute
}
}
}
Core Interface Methods
| Method | Return Type | Description |
|---|---|---|
getClassMetadata() |
iterable<object> |
Returns all attributes from a class (including traits) |
getPropertyMetadata() |
iterable<object> |
Returns all attributes from a property |
getFunctionMetadata() |
iterable<object> |
Returns all attributes from a method/function |
getConstantMetadata() |
iterable<object> |
Returns all attributes from a class constant |
getParameterMetadata() |
iterable<object> |
Returns all attributes from a function parameter |
firstClassMetadata() |
?object |
Returns first matching class attribute or null |
firstPropertyMetadata() |
?object |
Returns first matching property attribute or null |
firstFunctionMetadata() |
?object |
Returns first matching function attribute or null |
firstConstantMetadata() |
?object |
Returns first matching constant attribute or null |
firstParameterMetadata() |
?object |
Returns first matching parameter attribute or null |
Read attributes from classes using reflection. The reader automatically includes attributes from traits used by the class.
Read all attributes:
$reflection = new \ReflectionClass(User::class);
// Get all attribute objects
$attributes = $reader->getClassMetadata($reflection);
foreach ($attributes as $attribute) {
// Process each attribute object
}
Filter by specific type:
use Cycle\Annotated\Annotation\Entity;
$reflection = new \ReflectionClass(User::class);
// Get only Entity attributes
$entities = $reader->getClassMetadata($reflection, Entity::class);
foreach ($entities as $entity) {
echo $entity->getTable(); // Access Entity properties
}
Get single attribute instance:
use Cycle\Annotated\Annotation\Entity;
$reflection = new \ReflectionClass(User::class);
// Get first Entity attribute or null
$entity = $reader->firstClassMetadata($reflection, Entity::class);
if ($entity !== null) {
echo $entity->getTable();
}
Trait attribute support (since v2.10.0):
Attributes from traits are automatically included when reading class attributes:
use Cycle\Annotated\Annotation\Entity;
use Cycle\Annotated\Annotation\Column;
use App\Behavior\CreatedAt;
use App\Behavior\UpdatedAt;
#[Entity(table: 'entities')]
class User
{
use TimestampTrait;
}
#[CreatedAt]
#[UpdatedAt]
trait TimestampTrait
{
#[Column(type: 'datetime')]
private \DateTimeImmutable $createdAt;
#[Column(type: 'datetime', nullable: true)]
private ?\DateTimeImmutable $updatedAt = null;
}
$reflection = new \ReflectionClass(User::class);
// Returns Entity, CreatedAt, UpdatedAt attributes
$attributes = $reader->getClassMetadata($reflection);
Read attributes from class properties to configure field behavior:
use Cycle\Annotated\Annotation\Column;
$reflection = new \ReflectionProperty(User::class, 'email');
// Get all property attributes
$attributes = $reader->getPropertyMetadata($reflection);
// Get specific attribute type
$columns = $reader->getPropertyMetadata($reflection, Column::class);
// Get first matching attribute
$column = $reader->firstPropertyMetadata($reflection, Column::class);
if ($column !== null) {
echo $column->getType(); // string
var_dump($column->isNullable()); // false
}
Read attributes from methods and functions to configure behavior or route registration:
use Spiral\Router\Annotation\Route;
$reflection = new \ReflectionMethod(UserController::class, 'list');
// Get all method attributes
$attributes = $reader->getFunctionMetadata($reflection);
// Get specific attribute type
$routes = $reader->getFunctionMetadata($reflection, Route::class);
// Get first matching attribute
$route = $reader->firstFunctionMetadata($reflection, Route::class);
if ($route !== null) {
echo $route->getPath(); // /users
var_dump($route->getMethods()); // ['GET']
}
Works with both methods and functions:
// For class methods
$methodReflection = new \ReflectionMethod(SomeClass::class, 'someMethod');
$attributes = $reader->getFunctionMetadata($methodReflection);
// For standalone functions
$functionReflection = new \ReflectionFunction('someFunction');
$attributes = $reader->getFunctionMetadata($functionReflection);
Read attributes from class constants (PHP 8.0+):
use App\Metadata\Deprecated;
class StatusCodes
{
#[Deprecated(since: '2.0', alternative: 'STATUS_ACTIVE')]
public const STATUS_OK = 1;
public const STATUS_ACTIVE = 1;
}
$reflection = new \ReflectionClassConstant(StatusCodes::class, 'STATUS_OK');
// Get all constant attributes
$attributes = $reader->getConstantMetadata($reflection);
// Get first matching attribute
$deprecated = $reader->firstConstantMetadata($reflection, Deprecated::class);
if ($deprecated !== null) {
echo $deprecated->getSince(); // 2.0
echo $deprecated->getAlternative(); // STATUS_ACTIVE
}
Read attributes from function/method parameters for validation or injection configuration:
use App\Validation\Email;
use App\Validation\NotEmpty;
function sendEmail(
#[NotEmpty, Email] string $to,
#[NotEmpty] string $subject,
string $body
): void {
// Send email
}
$reflection = new \ReflectionParameter('sendEmail', 'to');
// Get all parameter attributes
$attributes = $reader->getParameterMetadata($reflection);
// Get specific attribute types
$validators = $reader->getParameterMetadata($reflection, NotEmpty::class);
// Get first matching attribute
$emailValidator = $reader->firstParameterMetadata($reflection, Email::class);
Works with method parameters:
class EmailService
{
public function send(
#[NotEmpty, Email] string $to,
#[NotEmpty] string $subject
): void {
// Send email
}
}
$reflection = new \ReflectionParameter([EmailService::class, 'send'], 'to');
$attributes = $reader->getParameterMetadata($reflection);
Define custom attribute classes for your application:
#[\Attribute(\Attribute::TARGET_CLASS)]
class Table
{
public function __construct(
public string $name,
public ?string $database = null,
) {}
}
Usage:
#[Table(name: 'users', database: 'main')]
class User {}
Specify where your attributes can be applied using the #[\Attribute] declaration:
| Target Constant | Applies To | Example |
|---|---|---|
TARGET_CLASS |
Classes | #[\Attribute(\Attribute::TARGET_CLASS)] |
TARGET_METHOD |
Methods | #[\Attribute(\Attribute::TARGET_METHOD)] |
TARGET_PROPERTY |
Properties | #[\Attribute(\Attribute::TARGET_PROPERTY)] |
TARGET_FUNCTION |
Functions | #[\Attribute(\Attribute::TARGET_FUNCTION)] |
TARGET_PARAMETER |
Parameters | #[\Attribute(\Attribute::TARGET_PARAMETER)] |
TARGET_CLASS_CONSTANT |
Constants | #[\Attribute(\Attribute::TARGET_CLASS_CONSTANT)] |
TARGET_ALL |
All | #[\Attribute(\Attribute::TARGET_ALL)] |
Multiple targets example:
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_PROPERTY)]
class Cached
{
public function __construct(
public int $ttl = 3600,
) {}
}
Allow the same attribute to be used multiple times on a single element:
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)]
class Tag
{
public function __construct(
public string $name,
) {}
}
Usage:
#[Tag('api')]
#[Tag('v1')]
#[Tag('user-management')]
class UserController {}
Use named constructor parameters for better IDE support and type safety:
#[\Attribute(\Attribute::TARGET_PROPERTY)]
class Column
{
public function __construct(
public string $type,
public bool $nullable = false,
public ?int $length = null,
public bool $unique = false,
) {}
}
Usage with named arguments:
#[Column(type: 'string', length: 255, unique: true)]
private string $email;
#[Column(type: 'integer', nullable: true)]
private ?int $age;
Benefits:
Use public properties for simple data containers:
#[\Attribute(\Attribute::TARGET_METHOD)]
class Route
{
public string $path;
public array $methods = ['GET'];
public ?string $name = null;
public function __construct(
string $path,
array $methods = ['GET'],
?string $name = null,
) {
$this->path = $path;
$this->methods = $methods;
$this->name = $name;
}
}
Add validation logic in attribute constructors:
#[\Attribute(\Attribute::TARGET_PROPERTY)]
class Range
{
public function __construct(
public int $min,
public int $max,
) {
if ($min > $max) {
throw new \InvalidArgumentException('Min cannot be greater than max');
}
}
}
The component provides several reader implementations:
The default and recommended reader for PHP 8+ attributes:
use Spiral\Attributes\AttributeReader;
$reader = new AttributeReader();
$attributes = $reader->getClassMetadata(new \ReflectionClass(User::class));
Use the factory to create a properly configured reader:
use Spiral\Attributes\Factory;
// Create default reader
$reader = (new Factory())->create();
// With cache
$reader = (new Factory())
->withCache($cacheImplementation)
->create();
Attribute reading involves reflection, which can be expensive. Use caching for production environments.
use Spiral\Attributes\Psr6CachedReader;
use Spiral\Attributes\AttributeReader;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
$cache = new FilesystemAdapter();
$reader = new Psr6CachedReader(
new AttributeReader(),
$cache
);
// First call: reads and caches
$attributes = $reader->getClassMetadata(new \ReflectionClass(User::class));
// Subsequent calls: returns from cache
$attributes = $reader->getClassMetadata(new \ReflectionClass(User::class));
use Spiral\Attributes\Psr16CachedReader;
use Spiral\Attributes\AttributeReader;
use Symfony\Component\Cache\Psr16Cache;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
$cache = new Psr16Cache(new FilesystemAdapter());
$reader = new Psr16CachedReader(
new AttributeReader(),
$cache
);
$attributes = $reader->getClassMetadata(new \ReflectionClass(User::class));
The simplest approach:
use Spiral\Attributes\Factory;
$reader = (new Factory())
->withCache($cacheImplementation) // PSR-6 or PSR-16
->create();
Performance considerations:
For automatic discovery of classes with specific attributes, use the Tokenizer component.
The Tokenizer can scan your codebase to find all classes that use specific attributes, making it perfect for:
Example using Tokenizer:
use Spiral\Tokenizer\TokenizationListenerInterface;
use Spiral\Attributes\ReaderInterface;
#[\Spiral\Tokenizer\Attribute\TargetAttribute(Route::class)]
class RouteListener implements TokenizationListenerInterface
{
private array $routes = [];
public function __construct(
private readonly ReaderInterface $reader
) {}
public function listen(\ReflectionClass $class): void
{
foreach ($class->getMethods() as $method) {
$route = $this->reader->firstFunctionMetadata($method, Route::class);
if ($route !== null) {
$this->routes[] = [
'path' => $route->path,
'handler' => [$class->getName(), $method->getName()],
];
}
}
}
public function finalize(): void
{
// Register all discovered routes
foreach ($this->routes as $route) {
// Add to router
}
}
}
See also Component — Static analysis for detailed information on class discovery and automatic registration.
use Spiral\Attributes\ReaderInterface;
use Spiral\Router\Annotation\Route;
class RouteRegistrar
{
public function __construct(
private readonly ReaderInterface $reader
) {}
public function registerController(string $class): array
{
$routes = [];
$reflection = new \ReflectionClass($class);
foreach ($reflection->getMethods() as $method) {
$route = $this->reader->firstFunctionMetadata($method, Route::class);
if ($route !== null) {
$routes[] = [
'path' => $route->getPath(),
'methods' => $route->getMethods(),
'handler' => [$class, $method->getName()],
];
}
}
return $routes;
}
}
use Spiral\Attributes\ReaderInterface;
class EntityValidator
{
public function __construct(
private readonly ReaderInterface $reader
) {}
public function validate(object $entity): array
{
$errors = [];
$reflection = new \ReflectionClass($entity);
foreach ($reflection->getProperties() as $property) {
$validators = $this->reader->getPropertyMetadata($property);
foreach ($validators as $validator) {
if (!$this->checkValidator($validator, $property, $entity)) {
$errors[] = sprintf(
'Property %s failed validation: %s',
$property->getName(),
$validator::class
);
}
}
}
return $errors;
}
private function checkValidator(object $validator, \ReflectionProperty $property, object $entity): bool
{
// Validation logic based on validator type
$property->setAccessible(true);
$value = $property->getValue($entity);
return match (true) {
$validator instanceof NotEmpty => !empty($value),
$validator instanceof Email => filter_var($value, FILTER_VALIDATE_EMAIL) !== false,
$validator instanceof MinLength => strlen($value) >= $validator->length,
default => true,
};
}
}
use Spiral\Attributes\ReaderInterface;
use App\Attributes\Listener;
class ListenerRegistrar
{
public function __construct(
private readonly ReaderInterface $reader
) {}
public function discoverListeners(array $classes): array
{
$listeners = [];
foreach ($classes as $class) {
$reflection = new \ReflectionClass($class);
foreach ($reflection->getMethods() as $method) {
$listener = $this->reader->firstFunctionMetadata($method, Listener::class);
if ($listener !== null) {
$listeners[$listener->event][] = [
'handler' => [$class, $method->getName()],
'priority' => $listener->priority ?? 0,
];
}
}
}
// Sort by priority
foreach ($listeners as &$eventListeners) {
usort($eventListeners, fn($a, $b) => $b['priority'] <=> $a['priority']);
}
return $listeners;
}
}
#[\Attribute(\Attribute::TARGET_PARAMETER)]
class Inject
{
public function __construct(
public ?string $id = null,
) {}
}
class ServiceFactory
{
public function __construct(
private readonly ReaderInterface $reader,
private readonly ContainerInterface $container,
) {}
public function createInstance(string $class): object
{
$reflection = new \ReflectionClass($class);
$constructor = $reflection->getConstructor();
if ($constructor === null) {
return new $class();
}
$args = [];
foreach ($constructor->getParameters() as $parameter) {
$inject = $this->reader->firstParameterMetadata($parameter, Inject::class);
if ($inject !== null) {
$args[] = $this->container->get($inject->id ?? $parameter->getType()->getName());
} else {
$args[] = $this->container->get($parameter->getType()->getName());
}
}
return $reflection->newInstanceArgs($args);
}
}
Use specific attribute filtering:
// ❌ Avoid: Reading all attributes when you need specific type
$all = $reader->getClassMetadata($reflection);
foreach ($all as $item) {
if ($item instanceof Entity) {
// Process
}
}
// ✅ Prefer: Filter at read time
$entity = $reader->firstClassMetadata($reflection, Entity::class);
Cache in production:
// ❌ Avoid: No caching in production
$reader = new AttributeReader();
// ✅ Prefer: Cache for production performance
$reader = (new Factory())
->withCache($cache)
->create();
Handle missing attributes gracefully:
// ✅ Always check for null
$entity = $reader->firstClassMetadata($reflection, Entity::class);
if ($entity === null) {
throw new \RuntimeException('Entity attribute required on ' . $reflection->getName());
}
Validate in attribute constructors:
#[\Attribute]
class Range
{
public function __construct(
public int $min,
public int $max,
) {
if ($min > $max) {
throw new \InvalidArgumentException('min must be less than or equal to max');
}
}
}
Use descriptive attribute names:
// ❌ Avoid: Generic names
#[Config('table', 'users')]
// ✅ Prefer: Specific, clear names
#[Table(name: 'users')]
Group related attributes:
// Create attribute groups for related configuration
#[Entity(table: 'users')]
#[HasTimestamps]
#[SoftDeletes]
class User {}
Problem: "Attribute class not found" errors
Solution: Ensure attribute classes are autoloaded:
// Verify class exists before reading
if (!class_exists(MyAttribute::class)) {
throw new \RuntimeException('Attribute class not found: ' . MyAttribute::class);
}
$attributes = $reader->getClassMetadata($reflection);
Problem: Attributes not being detected
Solution: Verify attribute target matches usage:
// Wrong: Attribute declared for TARGET_CLASS
#[\Attribute(\Attribute::TARGET_CLASS)]
class MyAttribute {}
// Used on method - won't work!
class Example {
#[MyAttribute] // Error: attribute target mismatch
public function method() {}
}
// Correct: Use appropriate target
#[\Attribute(\Attribute::TARGET_METHOD)]
class MyAttribute {}
Problem: Performance issues
Solution: Enable caching and use specific filtering:
// Enable cache
$reader = (new Factory())->withCache($cache)->create();
// Use specific type filtering
$entity = $reader->firstClassMetadata($class, Entity::class);
Problem: Attribute constructor errors
Solution: Use named arguments for clarity:
// ❌ Avoid: Positional arguments are error-prone
#[Route('/users', ['GET', 'POST'])]
// ✅ Prefer: Named arguments are clear
#[Route(path: '/users', methods: ['GET', 'POST'])]
If you're migrating from Doctrine annotations to PHP attributes:
// Old (Doctrine annotation)
/**
* @Entity(table="users")
*/
class User {}
// New (PHP attribute)
#[Entity(table: 'users')]
class User {}
@Annotation doc comments, add #[\Attribute]
For projects still using Doctrine annotations, consider the legacy compatibility features in previous versions of this component, though migration to native PHP attributes is recommended.