In PHP, how do you declare a custom attribute class such as #[Route] and read it back from controller methods at run time?
answer
- an ordinary class marked #[Attribute]
- constructor receives the arguments
- getAttributes() returns ReflectionAttribute objects
- getArguments() vs newInstance()
- ReflectionAttribute::IS_INSTANCEOF for subclasses
basics
~10 sWrite an ordinary class marked #[Attribute] whose constructor takes the attribute's arguments, put #[Route('/users')] on a method, then call getAttributes(Route::class) on its ReflectionMethod and newInstance() on each ReflectionAttribute returned.
solid answer
~40 sAn attribute class is a normal, non-abstract class carrying `#[Attribute]`; its constructor parameters define the arguments, and readonly promoted properties make it a small value object. You attach it with `#[Route('/users', methods: ['GET'])]`. To read it, reflect the declaration and call `getAttributes(?string $name = null, int $flags = 0)`, which exists on `ReflectionClass`, `ReflectionFunctionAbstract`, `ReflectionProperty`, `ReflectionClassConstant`, `ReflectionParameter` and `ReflectionConstant`. It returns an array of `ReflectionAttribute`, not attribute objects: `getName()` gives the resolved class name, `getArguments()` the raw arguments, and `newInstance()` autoloads the class, validates target and repetition, and calls the constructor. A name filter matches exactly; add `ReflectionAttribute::IS_INSTANCEOF` to include subclasses and implementations.
code
php · 25 lines<?php
declare(strict_types=1);
#[Attribute(Attribute::TARGET_METHOD)]
final class Route
{
public function __construct(
public readonly string $path,
public readonly array $methods = ['GET'],
) {}
}
final class UserController
{
#[Route('/users', methods: ['GET', 'HEAD'])]
public function list(): string { return 'users'; }
}
foreach ((new ReflectionClass(UserController::class))->getMethods() as $method) {
foreach ($method->getAttributes(Route::class) as $attr) {
$route = $attr->newInstance();
echo implode('|', $route->methods), ' ', $route->path, ' -> ', $method->getName(), PHP_EOL;
}
}
// GET|HEAD /users -> listgo deeper
Remember the three pieces: a class marked #[Attribute], the #[Name(args)] usage, and getAttributes() plus newInstance() to read it.
Explain the ReflectionAttribute stage in between, what getArguments() returns for positional and named arguments, and when IS_INSTANCEOF is needed.
Design attribute classes as validated readonly value objects, decide where newInstance() errors should surface, and cache instances instead of rebuilding them per call.
Decide which metadata belongs in attributes at all versus explicit configuration, since attribute-driven wiring hides behaviour from the call site and from code review.
## Declaring the attribute class A PHP **attribute** is backed by an ordinary class. What makes it an attribute class is the built-in `#[Attribute]` marker on the class itself: - the class must be concrete — since PHP 8.5, `#[Attribute]` on an abstract class, interface, trait or enum is a compile-time error; - its **constructor parameters** define what arguments the attribute accepts, including defaults, types and named arguments; - readonly promoted properties (`public function __construct(public readonly string $path)`) are the usual shape, because an attribute instance is a value, not a service; - an optional bitmask on the marker, such as `#[Attribute(Attribute::TARGET_METHOD)]`, restricts where it may be placed. The marker is imported like any class: `use Attribute;` or written as `#[\Attribute]`. ## Attaching it On the controller method you write `#[Route('/users', methods: ['GET'])]`. Several attributes can be grouped as `#[Route('/users'), RequiresRole('admin')]` or written on separate lines. Arguments may be positional or named and must be constant expressions. ## Reading it back Reading is a two-stage process through the Reflection API: 1. **Reflect the declaration** — `new ReflectionMethod(UserController::class, 'list')`, or iterate `(new ReflectionClass($class))->getMethods()`. 2. **List the attributes** — `getAttributes()` returns an array of `ReflectionAttribute` in the order they were written. Nothing is autoloaded or constructed at this stage. 3. **Inspect or instantiate** each one. | `ReflectionAttribute` method | Returns | |---|---| | `getName()` | the fully qualified class name after `use` resolution | | `getArguments()` | the raw arguments: integer keys for positional, string keys for named | | `getTarget()` | an `Attribute::TARGET_*` value for where it was found | | `isRepeated()` | whether the same attribute appears more than once on this declaration | | `newInstance()` | an object of the attribute class, built by calling its constructor | `newInstance()` is where all the checks happen: the class is autoloaded (`Attribute class "X" not found` if it cannot be), it must carry `#[Attribute]`, the target and repetition flags are enforced, and the arguments are passed to the constructor. That constructor call respects the `declare(strict_types=1)` of the file where the attribute was **written**, not of the file calling `newInstance()`. Each call builds a fresh object, so cache the instances if you read them often. ## Where getAttributes() is available Every kind of declaration that can carry an attribute has a reflector with the same `getAttributes(?string $name = null, int $flags = 0)` method: - `ReflectionClass` (and `ReflectionObject`, `ReflectionEnum`) for classes, interfaces, traits and enums; - `ReflectionFunction` and `ReflectionMethod`, through their shared parent `ReflectionFunctionAbstract`; - `ReflectionProperty` and `ReflectionClassConstant`; - `ReflectionParameter` for individual parameters; - `ReflectionConstant` for global constants, new in PHP 8.5. One detail surprises people: an attribute written on a **promoted constructor parameter** is replicated to both the parameter and the property it declares, so `#[Email] public string $address` in a constructor shows up on both `ReflectionParameter` and `ReflectionProperty`. A validator that scans properties and a container that scans parameters will each see it. ## Filtering by name and by type `getAttributes(Route::class)` returns only attributes whose resolved name is exactly `Route`. That is often too strict: a permission system may define `RequiresRole` and `RequiresScope`, both extending an abstract `Permission` base or implementing a `PermissionAttribute` interface. Passing the flag `ReflectionAttribute::IS_INSTANCEOF` — `getAttributes(PermissionAttribute::class, ReflectionAttribute::IS_INSTANCEOF)` — matches every attribute whose class is that type or a subtype. With that flag the filter class must exist (otherwise an `Error` "Class ... not found"), and any flag value other than `0` or `IS_INSTANCEOF` throws a `ValueError`. ## getArguments() versus newInstance() - **`getArguments()`** is cheap and never touches the attribute class, which suits tooling that only needs the literal values, or code that must cope with attribute classes that are not installed. - **`newInstance()`** gives a typed, validated object and runs whatever checks the constructor performs, such as rejecting a path without a leading `/`. Most framework code uses `newInstance()` so that bad metadata fails loudly. ## Common mistakes - Forgetting `#[Attribute]` on the class: `newInstance()` throws `Attempting to use non-attribute class "..." as attribute`. - Expecting `getAttributes()` to return `Route` objects directly. - Filtering by a base class without `IS_INSTANCEOF` and getting an empty array. - Putting service dependencies in the attribute constructor: PHP calls it with the literal arguments only, so there is nothing to inject.
- Which strict_types setting applies when newInstance() calls a PHP attribute's constructor?The one in the file where the attribute is written. `#[MyAttr("42")]` with an `int` parameter is coerced to `42` in a weak-mode file but throws a `TypeError` if that file declares `strict_types=1`, whatever mode the file calling `newInstance()` uses.
- When would you use ReflectionAttribute::getArguments() instead of newInstance()?When you need only the literal values and must not depend on the attribute class being loadable, for example a code generator or an analyser scanning third-party code. It skips autoloading, constructor checks and target validation, so the values are unvalidated.
- What happens if you pass an interface name to getAttributes() without any flag?It matches only attributes whose resolved class name equals that interface name exactly, which is usually none. Add `ReflectionAttribute::IS_INSTANCEOF` to match attribute classes implementing it; with that flag PHP must be able to load the interface.
saying these in an interview costs you the question
- getAttributes() returns instances of the attribute classes directly.
- An attribute class must extend a special base class or implement an interface.
- getAttributes(Base::class) automatically includes subclasses of Base.
- The attribute constructor runs when the controller class is loaded.
- Attribute constructors can receive services from the dependency container.