Introduction
Most applications start with a few simple decisions living in ordinary code. A controller calls a service. A command has a signature. A model has a scope. A webhook event maps to a handler.
Then the application grows.
Now we need to describe rules that belong to a class, method, property, or parameter, but are not the implementation of that thing. We reach for arrays in service providers, naming conventions, PHPDoc tags, switch statements, or comments that slowly become part of the application's contract.
For example, a webhook dispatcher might need to know which handler accepts invoice.paid. We could
keep that mapping in a central array:
$handlers = [
'invoice.paid' => RecordInvoicePayment::class,
'subscription.cancelled' => CancelSubscription::class,
];
This works, but the information is separated from the handler it describes. When someone creates a new handler, they must remember a second place that needs to change. That is a small form of coupling, and it becomes expensive when the mapping gets more detailed.
PHP attributes give us another option. They let us attach structured, machine-readable metadata directly to code. A framework or application can then read that metadata and turn it into behavior.
Since PHP 8, attributes have become a first-class language feature. Laravel uses them for things like Eloquent scopes and contextual dependency injection, but the idea is much bigger than any one framework.
In this article, let's do a deep dive into PHP attributes: what they are, why they are useful, how to build and consume them, and just as importantly, when they are the wrong tool.
Attributes Are Metadata, Not Behavior
The most important mental model is this:
An attribute describes code. It does not execute code by itself.
An attribute is metadata attached to a PHP declaration. PHP can attach that metadata to classes, methods, functions, properties, class constants, and parameters. The metadata is structured because it is represented by a real PHP class with a constructor and typed properties.
Here is a small example:
use Attribute;
#[Attribute(Attribute::TARGET_CLASS)]
final readonly class Audit
{
public function __construct(
public string $stream,
) {}
}
#[Audit('orders')]
final class OrderPlaced
{
}
OrderPlaced still behaves exactly as it did before. PHP does not automatically create an audit
record, publish an event, or call a logger just because it finds #[Audit('orders')].
The attribute only says:
This class has audit metadata and its stream is
orders.
Something else must read that declaration and decide what it means. That something might be your application, a framework, a package, a test runner, or a static analysis tool.
This is why attributes are part of metaprogramming. They let code reason about other code. A consumer can inspect the shape and metadata of a class, then use that information to build a registry, resolve a dependency, register a route, apply a scope, or change another part of the runtime.
The PHP manual describes attributes as a declarative way to add structured, machine-readable metadata. That is a much better description than calling them annotations with square brackets.
Attributes Versus PHPDoc and Configuration
Attributes are often compared with PHPDoc annotations because both sit close to code and describe it. They are useful for different jobs.
PHPDoc is primarily documentation and static-analysis metadata:
/**
* @return array<string, int>
*/
public function totals(): array
{
// ...
}
Tools like PHPStan, IDEs, and documentation generators can read this information. PHP itself does not create a runtime object from that docblock.
An attribute is a native PHP construct with a class, constructor arguments, target restrictions, and a Reflection API:
#[Cacheable(key: 'dashboard.summary', seconds: 60)]
public function summary(): array
{
// ...
}
That makes attributes a good fit when application or framework code must consume the metadata at runtime.
Configuration is a different category again. Configuration answers questions that can change between environments or deployments:
// config/services.php
'partner_api' => [
'base_url' => env('PARTNER_API_URL'),
'timeout' => env('PARTNER_API_TIMEOUT', 10),
],
An attribute should not try to replace configuration:
// Do not do this.
#[PartnerApi(baseUrl: config('services.partner_api.base_url'))]
final class PartnerClient
{
}
Attribute arguments belong in source code and must be values PHP can evaluate in the declaration, such as scalars, arrays, constants, enum cases, and class names. They are not a place to run runtime functions, read tenant settings, or pull secrets from the environment.
So a useful distinction is:
- use PHPDoc for developer-facing and static-analysis information
- use attributes for stable metadata that a runtime consumer should inspect
- use configuration for values that vary by environment, deployment, or operation
These tools complement each other. Trying to make one replace all the others usually creates a confusing API.
The Anatomy of an Attribute
An attribute starts with an ordinary class marked by PHP's built-in Attribute attribute:
use Attribute;
#[Attribute(Attribute::TARGET_CLASS)]
final readonly class HandlesWebhook
{
public function __construct(
public string $event,
public bool $verifySignature = true,
) {}
}
There are three important details here.
First, #[Attribute(...)] tells PHP that HandlesWebhook may be used as an attribute. Without that
declaration, it is just another class.
Second, Attribute::TARGET_CLASS restricts where the attribute can be used. PHP provides target
constants for classes, functions, methods, properties, class constants, and parameters. You can
combine targets with a bitmask:
#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_FUNCTION)]
final readonly class Retries
{
public function __construct(
public int $times,
) {}
}
Target restrictions are worth adding. They make the intent obvious and stop someone from placing an attribute on a property when only a method can sensibly use it.
Third, the constructor is the attribute's public API. Use normal PHP types, clear parameter names, defaults, and named arguments where they make a declaration easier to read:
#[RateLimit(
name: 'partner-api',
attempts: 60,
perSeconds: 60,
)]
public function store(PartnerRequest $request): Response
{
// ...
}
The code being decorated does not need to know anything about the attribute. The relationship goes in the other direction: the consumer knows the attribute and inspects the decorated code.
The Attribute Lifecycle
The lifecycle is simple, but understanding each step prevents a lot of confusion:
flowchart LR
A[Declare an attribute class] --> B[Attach metadata to a handler]
B --> C[Reflect the handler class]
C --> D[Read ReflectionAttribute metadata]
D --> E[Instantiate the attribute]
E --> F[Build a registry or framework rule]
F --> G[Use the prepared rule at runtime]
Attribute metadata becomes useful only when a consumer reflects it and deliberately turns it into a runtime rule.
The declaration is the code that owns the metadata:
#[HandlesWebhook('invoice.paid')]
final class RecordInvoicePayment
{
}
Then a consumer inspects the class with Reflection:
$class = new ReflectionClass(RecordInvoicePayment::class);
$attributes = $class->getAttributes(HandlesWebhook::class);
At this point, $attributes contains ReflectionAttribute objects, not HandlesWebhook objects.
That distinction is useful. Reflection lets us inspect the declaration without eagerly constructing
every attribute in the application.
When the consumer needs the typed metadata object, it calls newInstance():
$metadata = $attributes[0]->newInstance();
$metadata->event;
// invoice.paid
This is the moment where PHP calls the attribute constructor. Keep attribute constructors small, data-focused, and free of side effects. A constructor that opens a database connection or writes a log entry makes metadata discovery unpredictable and expensive.
Why Use Attributes?
The main benefit of attributes is locality. The metadata can sit next to the declaration it describes instead of being repeated in a distant registry.
Our webhook handler can now describe itself:
#[HandlesWebhook('invoice.paid')]
final class RecordInvoicePayment implements WebhookHandler
{
public function handle(array $payload): void
{
// Record the payment.
}
}
Someone reading RecordInvoicePayment immediately sees that it is a webhook handler and which event
it accepts. Someone adding another handler has one obvious place to declare the event.
Attributes also give us a typed contract. Compare this array-based mapping:
$handlers = [
'invoice.paid' => [
'handler' => RecordInvoicePayment::class,
'verify_signature' => true,
],
];
With this declaration:
#[HandlesWebhook(event: 'invoice.paid', verifySignature: true)]
final class RecordInvoicePayment implements WebhookHandler
{
// ...
}
The attribute constructor describes the accepted fields and their types. The application can validate the relationship when it builds its registry instead of relying on array keys that are easy to miss or misspell.
Other benefits follow from that:
- Discoverability: metadata appears beside the class, method, or parameter it affects.
- Consistency: a single attribute class gives every declaration the same vocabulary.
- Framework integration: a framework can provide a small expressive API without requiring a large amount of manual registration code.
- Tooling: IDEs can navigate to the attribute class and show its constructor parameters.
- Validation: targets and constructor types make invalid declarations fail earlier and more clearly than an ad-hoc associative array.
But none of these benefits are automatic. An attribute only improves a design when the metadata belongs naturally to the declaration and the consumer remains easy to understand.
Building a Practical Attribute
Let's turn the webhook example into a small, complete design.
We start with an interface. The interface is the behavior contract. The attribute is only metadata; it should not replace the contract that tells us how to run a handler.
interface WebhookHandler
{
public function handle(array $payload): void;
}
Next, we define an attribute that can be applied only to classes:
use Attribute;
#[Attribute(Attribute::TARGET_CLASS)]
final readonly class HandlesWebhook
{
public function __construct(
public string $event,
public bool $verifySignature = true,
) {}
}
Now a handler has both parts of its contract in clear places:
#[HandlesWebhook(event: 'invoice.paid')]
final class RecordInvoicePayment implements WebhookHandler
{
public function handle(array $payload): void
{
// Store the payment and update the invoice.
}
}
The interface says, "this class can process a webhook payload." The attribute says, "this is the specific event it processes, and its signature must be verified."
That separation is important. If a handler must have a handle() method, use an interface or an
abstract class. If the framework needs extra descriptive information about that handler, use an
attribute.
Reading Attributes With Reflection
The consumer can scan a known list of handlers when the application boots. It reads the attribute, checks for duplicate events, and creates a direct lookup table for later requests.
use LogicException;
use ReflectionClass;
final class WebhookHandlerRegistry
{
/** @var array<string, class-string<WebhookHandler>> */
private array $handlers = [];
/**
* @param list<class-string<WebhookHandler>> $handlerClasses
*/
public function __construct(array $handlerClasses)
{
foreach ($handlerClasses as $handlerClass) {
$attributes = (new ReflectionClass($handlerClass))
->getAttributes(HandlesWebhook::class);
if ($attributes === []) {
continue;
}
/** @var HandlesWebhook $metadata */
$metadata = $attributes[0]->newInstance();
if (array_key_exists($metadata->event, $this->handlers)) {
throw new LogicException("Webhook event [{$metadata->event}] has multiple handlers.");
}
$this->handlers[$metadata->event] = $handlerClass;
}
}
/** @return class-string<WebhookHandler> */
public function handlerFor(string $event): string
{
return $this->handlers[$event]
?? throw new LogicException("No handler exists for webhook event [{$event}].");
}
}
The dispatcher can now ask the registry for a class rather than reflecting every handler on each request:
$handlerClass = $registry->handlerFor($event);
$handler = app($handlerClass);
$handler->handle($payload);
This is the part that produces behavior. HandlesWebhook never dispatches anything by itself. The
registry and dispatcher give the metadata a concrete meaning.
There are a few deliberate decisions in this example:
- The registry knows the small set of classes it is allowed to inspect. Do not scan every class in a production codebase on every request.
- The registry builds a simple
event => handler classmap. Reflection happens during discovery; runtime dispatch is a normal array lookup. - Duplicate declarations are rejected early. Ambiguous metadata should fail during boot or cache warm-up, not silently select an arbitrary handler in production.
- The interface remains the behavior contract. An attribute cannot prove that a class can handle a webhook.
This is a pattern I like for attributes: use reflection to build explicit runtime data, then keep the hot path boring.
Targets and Repeatable Attributes
Target restrictions are more than a small syntax detail. They are part of the attribute's design.
For example, an attribute that tells a container where to get a value belongs on a parameter, not on the class that receives it:
#[Attribute(Attribute::TARGET_PARAMETER)]
final readonly class FromHeader
{
public function __construct(
public string $name,
) {}
}
An attribute can allow more than one target:
#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_FUNCTION)]
final readonly class Transactional
{
}
By default, an attribute can be used once on a declaration. That is correct for metadata such as a single table name, a single scope name, or a single webhook event.
Some metadata is naturally a list. Middleware, tags, permissions, and subscriptions are common examples. For these cases, make the attribute repeatable:
#[Attribute(
Attribute::TARGET_CLASS |
Attribute::IS_REPEATABLE,
)]
final readonly class UsesMiddleware
{
/** @param class-string $middleware */
public function __construct(
public string $middleware,
) {}
}
Then the decorated class can declare more than one value:
#[UsesMiddleware(Authenticate::class)]
#[UsesMiddleware(VerifyWebhookSignature::class)]
final class PartnerWebhookController
{
}
The consumer must still decide how to use the values and in which order. Repeatable attributes do not magically create a middleware pipeline. They only make the metadata model match a declaration that may legitimately have several values.
Use repeatability because the domain needs a collection, not because it feels more flexible. A single attribute containing an ordered array can be clearer when the collection is one coherent setting.
Laravel Is a Good Consumer of Attributes
Laravel 13 provides useful examples because it keeps the metadata close to code while preserving the framework contracts behind it.
An Eloquent local scope can be marked with #[Scope]:
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
final class Post extends Model
{
#[Scope]
protected function published(Builder $query): void
{
$query->whereNotNull('published_at');
}
}
The method still contains the query behavior. The attribute tells Eloquent that this protected method
is a local scope. Laravel reads the metadata while it prepares the model's query API, allowing code
such as Post::published()->get() to remain expressive.
Laravel also uses attributes for contextual dependency injection:
use Illuminate\Container\Attributes\Config;
final readonly class ReportExporter
{
public function __construct(
#[Config('reports.timezone')]
private string $timezone,
) {}
}
Here the container is the consumer. It sees the attribute on a constructor parameter and resolves the requested configuration value. The parameter type still tells us what the class receives, while the attribute explains where that value comes from.
This is an important lesson from good framework use of attributes:
The attribute hides ceremony, but it should preserve intent.
#[Scope] says exactly what the method is. #[Config('reports.timezone')] says exactly which
application value is being injected. Both have a known consumer in the framework.
Attribute Arguments Should Be Data
Attribute declarations work best when their arguments are small, stable, and easy to inspect.
This is a good attribute API:
#[Attribute(Attribute::TARGET_METHOD)]
final readonly class Cacheable
{
public function __construct(
public string $key,
public int $seconds,
) {}
}
And this is a clear use:
#[Cacheable(key: 'catalog.featured', seconds: 300)]
public function featuredProducts(): array
{
// ...
}
The metadata is declarative. It tells a consumer what the cache rule is without performing any work while the class is loaded.
Be careful with an attribute API that starts accepting too many values:
#[Cacheable(
key: 'catalog.featured',
seconds: 300,
store: 'redis',
tags: ['catalog', 'products'],
varyBy: ['tenant', 'locale', 'user'],
lock: true,
staleWhileRevalidate: true,
)]
That may be valid in a mature caching system, but it may also be a signal that the actual behavior needs an explicit service or a dedicated cache policy object. Attributes are great at describing a concise rule. They are not always the best home for a full configuration language.
If the declaration becomes hard to read, the consumer has too many branches, or a change needs runtime state, move the logic into ordinary code.
Reflection, Performance, and Caching
Reflection is powerful, but it is not free. Creating a ReflectionClass, finding attributes, and
instantiating metadata objects has a cost. Usually it is small, but it can become noticeable if it
happens repeatedly in a hot path.
The wrong approach is to scan every handler on every incoming webhook:
foreach ($allClasses as $class) {
$attributes = (new ReflectionClass($class))
->getAttributes(HandlesWebhook::class);
// Find the matching event for every request...
}
The better approach is the one from our registry example:
- Discover and validate metadata during application boot, cache warm-up, or an explicit registration phase.
- Build a simple map or descriptor collection.
- Use that prepared data at runtime.
For a package, a service provider is often a good place to register known classes. For a larger application, you may generate and cache a metadata map during deployment. The exact mechanism depends on the application, but the principle stays the same:
Reflect at the edge. Use ordinary data in the hot path.
Caching also improves predictability. It gives the application one clear point where invalid targets, duplicate event names, or incorrect constructor arguments are discovered. That is much easier to diagnose than a reflection failure after a customer action reaches production.
Testing Attribute-Based Designs
Test the behavior produced by an attribute consumer, not only that a square-bracket declaration exists.
For our registry, the meaningful test is that an event resolves to the expected handler:
it('resolves the invoice paid handler', function (): void {
$registry = new WebhookHandlerRegistry([
RecordInvoicePayment::class,
]);
expect($registry->handlerFor('invoice.paid'))
->toBe(RecordInvoicePayment::class);
});
We should also test the important failure mode:
it('rejects duplicate webhook event handlers', function (): void {
new WebhookHandlerRegistry([
RecordInvoicePayment::class,
ProcessInvoicePaymentAgain::class,
]);
})->throws(LogicException::class);
These tests prove that the metadata has the intended effect. They remain useful even if the registry later stops using Reflection and reads a generated metadata file instead.
Sometimes testing the attribute itself is appropriate too. If a package exposes an attribute as a public extension point, a small reflection test can verify its targets and default values. But that should support behavior tests, not replace them.
When Attributes Are a Good Fit
Attributes are a good fit when the metadata has a natural owner and a clear consumer.
Use them when:
- a class, method, property, or parameter needs stable descriptive metadata
- the metadata should live beside the declaration it describes
- a framework, package, or well-defined application service will read it
- the behavior is cross-cutting, such as registration, serialization, validation metadata, routing, authorization metadata, caching hints, or dependency resolution
- the attribute arguments are small, static values rather than runtime input
- the consumer can validate declarations early and turn them into a simple runtime representation
Some practical examples are:
- an API serializer declaring which field name or normalizer it uses
- a message handler declaring the message type it handles
- a console command method declaring a scheduled task's descriptive metadata
- an Eloquent method declaring that it is a query scope
- a constructor parameter declaring a contextual dependency source
- a test class declaring a group, requirement, or data provider relationship
The shared characteristic is that the attribute explains the declaration. It does not hide the core business input or replace the behavior contract.
When Not to Use Attributes
Attributes can easily become global state with better syntax. That is the failure mode to avoid.
Do not use an attribute for required business input:
// Avoid hiding required checkout input in metadata or a global lookup.
final class ChargeOrder
{
public function handle(Order $order, PaymentMethod $method): void
{
// ...
}
}
The order and payment method are necessary for the operation to run correctly, so they belong in the method signature or a dedicated input object. An attribute would make the dependency hidden and much harder to test.
Avoid attributes when the value must vary by environment, tenant, user, time, feature flag, or request:
// This belongs in configuration or an explicit runtime policy.
#[PartnerApi(baseUrl: 'https://api.example.com')]
final class PartnerClient
{
}
The base URL may be different in local, staging, and production. It should come from configuration, not source metadata.
Do not use an attribute to replace polymorphism. If several classes perform the same operation differently, an interface, strategy, or explicit service selection is normally the better tool.
Also avoid attributes when they make the application flow invisible. This is risky:
#[Authorize('admin')]
#[Retry(3)]
#[Transactional]
#[Cacheable(key: 'reports', seconds: 60)]
public function generate(): Report
{
// ...
}
Each declaration might be reasonable in isolation. Together, they can make it impossible to answer basic questions: where is authorization enforced, which exceptions trigger retries, what transaction contains the work, and which cache key is used for which user?
An attribute is not security by itself. #[Authorize('admin')] protects nothing unless a real
consumer runs before the action and enforces an authorization decision. Keep that enforcement easy to
trace, test, and audit.
Finally, do not build a generic scanner for every class in app/ just because attributes make it
possible. Explicit registration is often easier to understand, faster to boot, and safer to change.
A Simple Decision Checklist
Before adding an attribute, I ask these questions:
- Does this metadata naturally belong to this declaration?
- Is the value stable source-level data rather than runtime configuration or business input?
- Can I point to the exact code that consumes this attribute?
- Would an interface, constructor argument, value object, or configuration entry make the dependency clearer?
- Can the consumer validate the declaration at boot or cache warm-up?
- Can the runtime use a prepared map instead of repeatedly reflecting classes?
- Will a developer understand the behavior by following the attribute class to its consumer?
If the answer to the third question is no, stop there. An attribute without a known consumer is only decorative syntax. It adds another thing a developer must understand without adding behavior.
If the design passes these questions, attributes can make a framework or application feel much more expressive without hiding important complexity.
Conclusion
PHP attributes are typed, structured metadata attached to declarations. They are not magic methods, not runtime configuration, and not behavior by themselves.
Their value comes from the contract between two pieces of code: the declaration that owns the metadata and the consumer that reflects it. In a good design, attributes keep stable intent close to the class, method, or parameter it describes, while the consumer validates that metadata and turns it into simple runtime data.
Use attributes for focused metadata such as scopes, handler registration, serialization rules, and contextual injection. Keep business inputs explicit, dynamic values in configuration, behavior contracts in interfaces or classes, and reflection outside hot paths.
When we make those boundaries clear, attributes give us a powerful way to remove ceremony while keeping our code understandable. And that is the kind of metaprogramming worth using.
I hope that you liked this article and if you do, don't forget to share this article with your friends!!! See ya!