PHP attributes provide a mechanism for adding metadata to classes, methods, and properties that can be accessed via the Reflection API, enabling cleaner code organization and powerful design patterns. The guide covers practical implementations in Laravel, performance considerations, testing strategies, and discusses when explicit code remains preferable to using attributes.
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.
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:
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.
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.
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')].
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.
Full story reconstructed from Wendelladriel.com. Formatting and media may differ from the original.
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 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:
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:
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:
An attribute should not try to replace configuration:
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.
These tools complement each other. Trying to make one replace all the others usually creates a confusing API.
An attribute starts with an ordinary class marked by PHP's built-in Attribute attribute:
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:
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:
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 lifecycle is simple, but understanding each step prevents a lot of confusion:
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:
Then a consumer inspects the class with Reflection:
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():
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.
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:
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:
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.
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.
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.
Next, we define an attribute that can be applied only to classes:
Now a handler has both parts of its contract in clear places:
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.
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.
The dispatcher can now ask the registry for a class rather than reflecting every handler on each request:
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 class map. 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.
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:
An attribute can allow more than one target:
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:
Then the decorated class can declare more than one value:
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 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]:
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:
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 declarations work best when their arguments are small, stable, and easy to inspect.
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:
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 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:
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.
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.
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:
We should also test the important failure mode:
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.
Attributes are a good fit when the metadata has a natural owner and a clear consumer.
The shared characteristic is that the attribute explains the declaration. It does not hide the core business input or replace the behavior contract.
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:
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:
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:
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.
Before adding an attribute, I ask these questions:
Is the value stable source-level data rather than runtime configuration or business input?
Would an interface, constructor argument, value object, or configuration entry make the dependency clearer?
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.
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!