Attributes
Attributes are structured metadata attached to PHP code. They let classes, methods, properties, parameters, functions, and constants carry information that tools, frameworks, or your own code can read with reflection.
Attributes do not run by themselves. They are data attached to code. Something else must read them and decide what they mean.
You will see attributes in routing, validation, ORM mapping, dependency injection, test configuration, serialization, security, event listeners, and framework integrations.
A Simple Attribute
An attribute is a normal PHP class marked with PHP's built-in Attribute attribute.
<?php
declare(strict_types=1);
#[Attribute(Attribute::TARGET_METHOD)]
final readonly class Route
{
public function __construct(
public string $method,
public string $path,
) {
}
}
final class UserController
{
#[Route('GET', '/users')]
public function index(): string
{
return 'User list';
}
}
Route is metadata on the index() method. By itself, it does not register a route. A router or scanner must inspect the method and read the attribute.
Reading Attributes With Reflection
Reflection lets code inspect classes and methods at runtime.
<?php
declare(strict_types=1);
#[Attribute(Attribute::TARGET_METHOD)]
final readonly class Route
{
public function __construct(
public string $method,
public string $path,
) {
}
}
final class UserController
{
#[Route('GET', '/users')]
public function index(): string
{
return 'User list';
}
}
$method = new ReflectionMethod(UserController::class, 'index');
$attributes = $method->getAttributes(Route::class);
foreach ($attributes as $attribute) {
$route = $attribute->newInstance();
echo $route->method . ' ' . $route->path . PHP_EOL;
}
// Prints:
// GET /users
getAttributes() returns reflection objects. newInstance() creates the actual attribute object using the arguments written in the attribute.
Attribute Targets
Attributes can declare where they are allowed.
<?php
declare(strict_types=1);
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
final readonly class RequiresRole
{
public function __construct(
public string $role,
) {
}
}
#[RequiresRole('admin')]
final class AdminController
{
#[RequiresRole('editor')]
public function publish(): void
{
}
}
Targets help catch mistakes. A route attribute should probably apply to methods, not random properties.
Repeatable Attributes
Some metadata may appear more than once. Use Attribute::IS_REPEATABLE.
<?php
declare(strict_types=1);
#[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
final readonly class Tag
{
public function __construct(
public string $name,
) {
}
}
#[Tag('billing')]
#[Tag('reports')]
final class InvoiceReport
{
}
$class = new ReflectionClass(InvoiceReport::class);
foreach ($class->getAttributes(Tag::class) as $attribute) {
echo $attribute->newInstance()->name . PHP_EOL;
}
// Prints:
// billing
// reports
Use repeatable attributes only when multiple entries make sense.
Attributes Versus Comments
Before attributes, PHP projects often used docblock annotations:
/**
* @Route("GET", "/users")
*/
Attributes are better for structured metadata because PHP parses them as code. Attribute classes can have constructors, typed arguments, target restrictions, and reflection support.
Comments are still useful for explanations and static analysis annotations, but they should not be the main mechanism for runtime metadata when attributes fit the problem.
Where Attributes Belong
Attributes are good when metadata belongs directly beside the code it describes.
Examples:
- route path beside a controller method
- validation rule beside a DTO property
- ORM column mapping beside an entity property
- test case metadata beside a test method
- listener metadata beside an event handler
Attributes are less useful when the metadata changes by environment, tenant, database row, or admin setting. Those values usually belong in configuration or data storage.
Common Mistakes
A common mistake is expecting attributes to do something automatically. They only matter if a framework, library, or your own reflection code reads them.
Another mistake is putting business logic inside attribute classes. Attribute constructors should usually store metadata, not query databases or call services.
Also avoid scattering too much behaviour across attributes. If a class has many attributes controlling unrelated systems, it can become hard to understand what happens at runtime.
Attributes Require PHP 8.0 Or Later
PHP introduced attributes in PHP 8.0. The #[...] syntax is parsed by the language and cannot be polyfilled for PHP 7.
A package containing attributes must set an accurate Composer PHP constraint and run CI against every supported PHP version. Docblock annotations remain relevant when a library must support older runtimes or interoperates with a tool whose metadata system is annotation-based.
Do not maintain duplicate attribute and annotation configuration unless a migration or compatibility requirement justifies the extra source of truth.
Attribute Arguments Are Restricted Expressions
Attribute arguments are part of code metadata. They can use literals, arrays, constants, enum cases, and other expressions permitted by PHP's attribute rules. They cannot depend on ordinary runtime variables.
<?php
declare(strict_types=1);
const DEFAULT_CACHE_SECONDS = 300;
#[Attribute(Attribute::TARGET_METHOD)]
final readonly class CacheFor
{
public function __construct(public int $seconds)
{
if ($seconds < 1) {
throw new InvalidArgumentException('Cache duration must be positive.');
}
}
}
final class ProductController
{
#[CacheFor(DEFAULT_CACHE_SECONDS)]
public function show(): void
{
}
}
The constructor runs when reflection calls newInstance(), not merely when PHP parses the attributed declaration. Invalid constructor values can therefore remain undiscovered until a scanner instantiates the metadata.
Validate metadata during tests or application compilation so failures happen before a production request.
Reflection Can Inspect Before Instantiating
getAttributes() returns ReflectionAttribute objects. Code can inspect their names and arguments without constructing the attribute instance.
<?php
declare(strict_types=1);
$method = new ReflectionMethod(ProductController::class, 'show');
$attributes = $method->getAttributes(CacheFor::class);
foreach ($attributes as $attribute) {
print_r($attribute->getArguments());
}
Call newInstance() when constructor validation or an object API is needed. Delaying construction can reduce work in scanners that filter many declarations.
Catch metadata errors at a clear bootstrap or build boundary. A malformed route attribute should not produce an unexplained failure halfway through handling unrelated traffic.
Filter By Attribute Type
Reflection can filter for one exact attribute class. It can also include attributes whose classes implement or extend a requested type by using ReflectionAttribute::IS_INSTANCEOF.
This supports a shared marker interface:
<?php
declare(strict_types=1);
interface HandlerMetadata
{
}
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final readonly class Handles implements HandlerMetadata
{
public function __construct(public string $event)
{
}
}
A scanner can request all HandlerMetadata implementations without hard-coding every concrete attribute. Keep the hierarchy small and purposeful; metadata abstraction can become as complex as the behavior it configures.
Repeatable Attributes Need A Merge Rule
Mark an attribute with Attribute::IS_REPEATABLE only when several instances are meaningful. Event subscriptions, route methods, tags, and validation rules may be repeatable.
<?php
declare(strict_types=1);
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final readonly class ListensTo
{
public function __construct(public string $event)
{
}
}
final class AuditSubscriber
{
#[ListensTo('OrderPlaced')]
#[ListensTo('OrderCancelled')]
public function record(object $event): void
{
}
}
Define duplicate behavior. Should two identical instances be rejected, deduplicated, or register the handler twice? Reflection returns declarations; the consuming system owns interpretation.
Ordering should not secretly determine business priority unless the metadata contract documents it. Add an explicit priority argument if ordering matters.
Attributes Do Not Automatically Inherit Meaning
Metadata attached to a parent class or method does not guarantee that a custom scanner will treat a child as having the same metadata. Reflection APIs expose declarations, while inheritance policy belongs to the consuming library.
A router might intentionally ignore parent route metadata. A security system might merge class and method requirements. An ORM may define its own mapping inheritance rules.
Document and test whether metadata is inherited, overridden, merged, or repeated. Do not assume ordinary PHP method inheritance answers the metadata question.
Named Arguments Improve Metadata Readability
Attribute constructors support named arguments:
<?php
declare(strict_types=1);
#[Attribute(Attribute::TARGET_METHOD)]
final readonly class RouteMetadata
{
public function __construct(
public string $path,
public string $method = 'GET',
public ?string $name = null,
) {
}
}
final class CheckoutController
{
#[RouteMetadata(path: '/checkout', method: 'POST', name: 'checkout.submit')]
public function submit(): void
{
}
}
Parameter names become compatibility surface. Renaming $path or $method can break named attribute use in other packages. Treat public attribute constructors as public APIs.
Metadata Should Stay Declarative
An attribute object should usually store and validate metadata. It should not open database connections, resolve services globally, or send network requests from its constructor.
The consumer performs behavior:
Route attribute describes method and path
Router scanner reads it
Router registers a callable
Request dispatcher invokes the callable later
This separation makes metadata inspection deterministic and testable. It also avoids expensive work when tools merely scan classes.
Scanning Has Performance And Deployment Costs
Discovering every class and reflecting every method on every request is wasteful. Frameworks often build or cache metadata during container compilation, route-cache generation, or application bootstrap.
A custom system should:
- scan an explicit namespace or class list;
- avoid loading unrelated files;
- validate duplicate routes or handlers;
- cache the compiled map;
- invalidate the cache during deployment;
- report source class and method in errors;
- avoid executing application behavior while scanning.
OPcache stores compiled PHP code, not the interpreted route map created by a custom scanner. Cache the derived metadata appropriately.
Attributes Versus Configuration
Use attributes when metadata is stable, code-owned, and naturally located beside the declaration. Route paths, serializer field rules, test metadata, and ORM mappings are common cases.
Use configuration or stored data when values vary by environment, tenant, deployment, or administrator. A production API endpoint, feature rollout percentage, secret, or customer-specific rule should not require editing an attribute and redeploying code unless that lifecycle is intended.
Central configuration can also be clearer when readers need to see the whole map at once. Attributes distribute metadata across classes. Choose the representation that supports ownership and review.
Attributes Versus Interfaces
An attribute describes a declaration. An interface enforces available behavior.
Do not use #[CanSendEmail] when the consuming code needs a callable send() method; define an interface. Use an attribute for information that the type system cannot or should not express, such as a route path or validation limit.
Sometimes both are useful: an attribute registers a class as a handler, while an interface guarantees its method contract.
Security Metadata Needs Fail-Closed Rules
Attributes can describe authorization requirements, but the enforcement system must handle missing, conflicting, and invalid metadata safely.
A scanner that treats an unknown role as public access creates a vulnerability. Prefer startup validation and fail closed. Test class-level and method-level merge behavior, overrides, inherited controllers, and default policies.
Do not put secrets in attribute arguments. Metadata can appear in source, reflection output, caches, stack traces, and generated files.
Test The Consumer, Not Only The Attribute Class
Testing the constructor proves value validation. It does not prove that a router, validator, ORM, or event dispatcher interprets metadata correctly.
Add tests for:
- allowed and rejected targets;
- repeatable ordering or deduplication;
- missing required metadata;
- named constructor arguments;
- inherited or overridden declarations;
- scanner cache generation;
- duplicate route or handler detection;
- safe failure for invalid security metadata;
- behavior after metadata-cache invalidation.
Use a small fixture class and assert the compiled registration map. This tests the contract between metadata and behavior.
Compatibility And Refactoring
When changing an attribute API, review:
- constructor parameter names and order;
- target flags;
- repeatability;
- default values;
- class renames and namespaces;
- inherited-consumer behavior;
- cached compiled metadata;
- supported PHP baseline.
A rename may require updating many attributed declarations and clearing generated caches. Static search and reflection-based validation can make the migration deterministic.
What You Should Be Able To Do
After this lesson, you should be able to define a custom attribute, apply it to valid targets, read it through reflection, and use repeatable attributes with an explicit merge rule. You should understand that constructors run during newInstance() and that consumers create behavior.
You should also be able to choose attributes over comments, interfaces, or configuration appropriately; design a bounded metadata scanner; protect security metadata; and manage PHP-version and parameter-name compatibility.
Practice
Practice: Read Route Attributes
Create a small PHP example that defines and reads a route attribute.
Task
Build:
- a
Routeattribute for methods - a controller method with a route attribute
- reflection code that reads the attribute and prints the HTTP method and path
Use strict types. Keep the expected output in the PHP code block as printed lines or comments.
Check Your Work
Confirm:
- the attribute is limited to methods
- the controller method has route metadata
- reflection reads the metadata
- the attribute does not register a route by itself
Afterward, explain why attributes are metadata rather than behaviour.
Show solution
This solution defines route metadata and reads it with reflection.
<?php
declare(strict_types=1);
#[Attribute(Attribute::TARGET_METHOD)]
final readonly class Route
{
public function __construct(
public string $method,
public string $path,
) {
}
}
final class UserController
{
#[Route('GET', '/users')]
public function index(): string
{
return 'User list';
}
}
$method = new ReflectionMethod(UserController::class, 'index');
$attributes = $method->getAttributes(Route::class);
foreach ($attributes as $attribute) {
$route = $attribute->newInstance();
echo $route->method . ' ' . $route->path . PHP_EOL;
}
// Prints:
// GET /users
The attribute is metadata because it only describes the method. A router, scanner, or other runtime code must read that metadata before it changes application behaviour.
Build Repeatable Listener Metadata
Create a repeatable method attribute ListensTo with event name and integer priority. Apply it twice to one subscriber method.
Use reflection to instantiate every attribute, reject duplicate event/priority pairs, sort by descending priority, and print the resulting registrations.
Show solution
The consuming scanner, not the attribute, owns deduplication and ordering. Tests should prove both repeated declarations are discovered and invalid duplicates fail during bootstrap.
Review An Attribute Scanner
A router scans every declared class on every request, instantiates every attribute, ignores duplicate paths, and treats invalid role metadata as public.
Identify the correctness, security, and performance problems. Design a compilation and cache workflow that fails before serving traffic.
Show solution
Scan only configured controller namespaces or a generated class list during build/bootstrap. Filter route and security attribute types before instantiation, validate arguments, reject duplicate method/path combinations, and fail closed on unknown roles.
Compile an immutable route map with source locations, cache it for runtime use, and regenerate it during deployment when code changes. Tests cover duplicates, inheritance policy, missing metadata, and cache invalidation.