skip to content

Attribute Declarations

PHP 8 attributes attach structured metadata with #[...], declared as classes marked #[Attribute] and read back through reflection. Interviewers ask how they differ from docblock annotations.

part ofPHPoverview, primer and where to startread it →
on this pageshow

explore

questions

6

In PHP, how do you declare a custom attribute class such as #[Route] and read it back from controller methods at run time?

level: middleimportance: must knowfreq 45%

answer

  1. an ordinary class marked #[Attribute]
  2. constructor receives the arguments
  3. getAttributes() returns ReflectionAttribute objects
  4. getArguments() vs newInstance()
  5. ReflectionAttribute::IS_INSTANCEOF for subclasses

basics

~10 s

Write 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 s

An 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
<?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 -> list

go deeper

for a junior

Remember the three pieces: a class marked #[Attribute], the #[Name(args)] usage, and getAttributes() plus newInstance() to read it.

for a middle

Explain the ReflectionAttribute stage in between, what getArguments() returns for positional and named arguments, and when IS_INSTANCEOF is needed.

for a senior

Design attribute classes as validated readonly value objects, decide where newInstance() errors should surface, and cache instances instead of rebuilding them per call.

for a principal

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.
open as a page

In PHP 8, how do native attributes written as #[...] differ from docblock annotations such as @Route inside a /** */ comment?

level: juniorimportance: should knowfreq 45%

basics

~20 s

Attributes are real PHP syntax: the engine parses them, resolves their names like class names and returns them through Reflection's getAttributes(). Docblock annotations are comment text a userland library must fetch with getDocComment() and parse itself.

open as a page

In PHP 8.2 and later, what does the #[\SensitiveParameter] attribute redact, and what does it leave exposed?

level: middleimportance: should knowfreq 22%

basics

~20 s

It replaces the marked argument with a SensitiveParameterValue object in every backtrace PHP builds, so exception traces and debug_backtrace() no longer print it. The function body, var_dump(), logging and callees without the attribute still see the real value.

open as a page

In PHP, what do Attribute::TARGET_* and Attribute::IS_REPEATABLE control on a custom attribute, and when is a violation actually reported?

level: middleimportance: should knowfreq 28%

basics

~20 s

The TARGET_* flags restrict which declarations an attribute may sit on and IS_REPEATABLE allows it more than once per declaration. For userland attributes PHP enforces both only when ReflectionAttribute::newInstance() runs, by throwing an Error; built-in attributes fail at compile time.

open as a page

A small PHP framework maps #[Route] and #[RequiresPermission] attributes on controller methods to routes and access checks; how should it scan, validate and cache them safely?

level: seniorimportance: should knowfreq 30%

basics

~20 s

Scan each controller method's getAttributes() once at build time, instantiate every attribute so errors fail CI, deny routes that lack a permission attribute, and write the result to a generated PHP file so requests never repeat the reflection scan.

open as a page

In PHP 8.4 and 8.5, what happens when code calls a function marked #[\Deprecated] or ignores the result of one marked #[\NoDiscard]?

level: middleimportance: nice to knowfreq 15%

basics

~20 s

Calling a #[\Deprecated] user function emits E_USER_DEPRECATED, including the optional since and message. Calling a #[\NoDiscard] function (PHP 8.5) as a bare statement emits a warning unless the result is used or cast with (void).

open as a page