PHP 8 Attributes in Practice: Moving Metadata Back onto the Code

PHP 8 attributes look like magic until you write one — and then you find out they do nothing at all. That turns out to be the whole point. Here's what the feature genuinely fixes, and what happened in a real package when two facts that had been hiding in a config file and a list of magic substrings finally moved onto the code they described.

  • #[Environment], walked through: /environments
  • #[Sensitive] and masking: /masking
  • The commands that read them: /commands
  • Working with an AI agent? The package ships an AI assistant skill that teaches Claude Code, Cursor and Codex its conventions.

Table of Contents

  1. Where This Started
  2. PHP 8 Attributes in Five Minutes
  3. What Came Before
  4. What Attributes Actually Solve
  5. The Honest Costs
  6. Case Study One: Declaring Environments on the Class
  7. Case Study Two: Replacing a Guess with a Declaration
  8. Implementation Details Worth Stealing
  9. When Not to Reach for an Attribute
  10. The Pattern Underneath

1. Where This Started

I'd used PHP attributes for years without ever designing one. Every Laravel developer has — routes, validation, casts — but consuming a feature and building on it are different kinds of understanding, and I only had the first.

Then I read Brent's Attributes in PHP 8, and two things in code I had already shipped stopped looking like design decisions and started looking like unfinished work. Both were the same shape: a fact about a class that lived somewhere other than the class. One in a config file, one in an undocumented naming convention. Both have since moved onto the code they describe.

This article is in two halves. The first is an honest look at what PHP attributes are and what they genuinely fix — independent of any package, because I think the feature is more interesting than any single use of it. The second is what happened when I applied that to real code, including the parts that didn't work the first time.


2. PHP 8 Attributes in Five Minutes

An attribute is structured metadata you attach to a declaration, written as #[Something]. Using one always involves three separate pieces of code, and keeping them distinct is what makes the feature click.

Say we want to mark which methods handle which events, so the framework can register them automatically.

Step 1 — Define the attribute. It is just a normal class. The only special thing about it is that it is itself marked with the built-in #[Attribute], which tells PHP this class is allowed to be used as one:

use Attribute;

#[Attribute]
class ListensTo
{
    public function __construct(
        public string $event,
    ) {}
}

Step 2 — Attach it. Now #[ListensTo(...)] can be written above a declaration. The arguments in the brackets are the arguments to that constructor:

class ProductSubscriber
{
    #[ListensTo(ProductCreated::class)]
    public function onProductCreated(ProductCreated $event) { }
}

This is the mental model I'd hold onto: #[ListensTo(ProductCreated::class)] is a new ListensTo(ProductCreated::class) that hasn't happened yet. The syntax records a constructor call; it doesn't perform one.

Step 3 — Read it back. And here is the part that surprises people coming from frameworks where attributes seem magical: on their own, attributes do nothing at all. PHP parses the line and moves on. There's no dispatcher, no hook, no lifecycle event. Nothing happens until your own code goes looking:

$method = new ReflectionMethod(ProductSubscriber::class, 'onProductCreated');

foreach ($method->getAttributes(ListensTo::class) as $attribute) {
    $listener = $attribute->newInstance();   // NOW the constructor runs
    $listener->event;                        // 'ProductCreated'
}

Two details in those three lines matter:

  • getAttributes() hands back ReflectionAttribute objects — descriptions of the attribute, not the attribute itself. They know the class name and the arguments, and that's all.
  • newInstance() is the moment the constructor actually executes. Until you call it, nothing of yours has run.

Optionally, passing ReflectionAttribute::IS_INSTANCEOF as a second argument to getAttributes() widens the filter to subclasses and interface implementations instead of requiring an exact class match.

So when Laravel or Symfony appears to "do something" with an attribute, what's really happening is that somewhere in the framework there is a step three like the one above, reflecting over your classes and acting on what it finds. Writing your own attribute means writing that step yourself — which is exactly what the two case studies later in this article are.

Four more facts that cover most of what you need:

Where they go. Classes, anonymous classes, properties, class constants, methods, functions, closures, parameters, and enums. Constructor-promoted parameters count, which matters more than it sounds.

What arguments are allowed. Constant expressions only — scalars, arrays, ::class references, bit operations, new in a constant context. No function calls, no runtime values, no closures. The argument list has to be resolvable without executing your application.

Targeting. The #[Attribute] marker takes a bitmask that constrains placement:

#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER)]
final class Sensitive {}

Put that on a class and you get an error — but only when something calls newInstance(). Validation is deferred to instantiation, not parse time. That timing is worth remembering, because it means a misplaced attribute in code nobody reflects over stays silent forever.

Repeatability. By default the same attribute can't appear twice on one declaration. Attribute::IS_REPEATABLE allows it, and getAttributes() then returns them in source order.

That's the whole feature. It is deliberately small.


3. What Came Before

None of this capability is new. Doctrine had annotations for over a decade, and the ecosystem was built on them — routing, ORM mapping, validation, serialization. What was new in PHP 8 is that the language does it.

Before attributes, metadata lived in one of three places, and each had a specific failure mode:

Docblocks. @ORM\Column(type="string") is a comment. To the engine it is dead text. Reading it meant shipping a parser, and that parser defined its own grammar with its own error messages. A typo produced either a runtime parse exception, or worse, silence. Your IDE couldn't rename through it, static analysers couldn't see it, and stripping comments in an opcode optimiser could break your application.

Configuration files. Move the metadata to YAML, XML, or a PHP array. Now it's structured and parseable, but it lives in a different file — and when the class ships inside a package, a different repository. Reading the class tells you nothing about how it will behave.

Naming conventions. The cheapest option: infer meaning from the name. handleProductCreated() handles ProductCreated. $api_secret is secret. No parser, no config, nothing to keep in sync. Also no way to express an exception, and no way to be told when the guess is wrong.

Every one of those three is an attempt to say something about code in a place that is not the code. Attributes are the first one where the language itself carries the statement.


4. What Attributes Actually Solve

Setting aside any specific use, here is what I think the feature genuinely buys — and I'd argue the first point is the one that matters and the rest follow from it.

Locality. The fact lives on the thing it describes. This sounds like a stylistic preference until you count what a reader has to know. With a config file, understanding a class requires knowing that a second file exists, where it is, and that it mentions this class. Nothing in the class says so. With an attribute, the statement is on the line above the declaration, and someone reading the code cannot miss it. Every other benefit below is a consequence of the metadata being in the same place as the code.

It's a class, so it has a schema. An attribute's constructor signature is its specification. Types are enforced, required arguments are required, named arguments work, defaults work, readonly promotion works. The docblock equivalent was a string that some parser hoped to understand. The difference between @Column(tpye="string") and a constructor that takes string $type is the difference between a runtime surprise and an error.

Targets turn misuse into an error. TARGET_METHOD is a declaration-site constraint. A comment could be written anywhere and simply not work; an attribute in the wrong place tells you.

Tooling can see it. ::class inside an attribute is a real class reference, so rename refactoring follows it. PHPStan and Psalm read attributes. IDEs autocomplete the constructor arguments. Static analysis over metadata is only possible when metadata is part of the AST — and with docblocks, it wasn't.

Metadata travels with the code. This is the one I underrated until I was on the other side of it. When a class ships inside a package, config-based metadata means the consumer has to configure something to make the class behave correctly. Attribute-based metadata means the class arrives already complete. For library authors this changes the shape of the API: the default becomes "works", not "works once you wire it up."

Repeatability expresses one-to-many honestly. A method that serves three environments, a route that answers two paths, a property with several validation rules — these are naturally lists. Three lines of three attributes reads better than one attribute holding an array, and it diffs better too.

Put together, what attributes resolve is a category of problem, not a task: the disconnect between a piece of code and the facts about it. Every workaround before this either put those facts somewhere else, or hid them in a convention. The feature is small because that's all it needs to be.


5. The Honest Costs

An independent look has to include the parts that aren't better.

Silence is the default failure mode. An attribute nobody reads does nothing, and reports nothing. Misspell the class, import the wrong namespace, or put it on a method your resolver never inspects, and there's no error — just behaviour you expected that didn't happen. Docblocks had exactly this problem, and attributes only partially fix it: the constructor is validated, but only if something calls newInstance().

Reflection isn't free. Reading attributes means reflecting, and doing it per request in a hot path is a real cost. Any serious use needs caching. Laravel caches routes and events for precisely this reason.

Constant expressions only. No runtime values. If the metadata depends on something computed at boot, an attribute can't hold it — and this is a hard boundary, not an inconvenience you can work around.

Attributes are not inherited onto an overridden method. Override a marked method in a subclass and the mark is gone unless you repeat it. This is consistent with how PHP treats declarations, and it is also the single most likely thing to bite you. I'll come back to this, because it cost me a bug.

Discoverability runs backwards. Given a class, you can see its attributes. Given a codebase, you cannot easily enumerate which attributes exist or which are actually honoured. A config file at least has the courtesy of being one file you can read top to bottom.


6. Case Study One: Declaring Environments on the Class

Both case studies come from laravel-env-settings (live demo), a package of mine that moves non-secret configuration out of .env and into typed PHP classes. All you need to know for what follows: a settings class defines one static factory per environment — development(), production(), and so on — and the package calls the right one at runtime.

Which raises an obvious question for anyone reading such a class: which APP_ENV values reach which method?

Before — the answer requires two files, and the class is the wrong one to open:

// app/Settings/PaymentSettings.php — says nothing about environments
class PaymentSettings extends EnvironmentSettings
{
    public static function development(): static { ... }
    public static function production(): static  { ... }
}

// config/env-settings.php — the answer lives here, in a different file
'environment_map' => [
    'local'      => 'development',
    'staging'    => 'staging',
    'production' => 'production',
    'prod'       => 'production',
],

After — the class answers the question itself:

// app/Settings/PaymentSettings.php — the whole story, one file
class PaymentSettings extends EnvironmentSettings
{
    #[Environment('local', 'dev')]
    public static function development(): static { ... }

    #[Environment('production', 'prod')]
    #[Environment('demo')]
    public static function production(): static  { ... }
}

The difference is not fewer lines — it's that the "after" version cannot be read incorrectly. Open the "before" class and you see three method names and no way to tell which environment strings reach them, what else is aliased, or whether some environment silently falls through to a default. The answer is in another file, and when the settings class ships inside a package, it's in another repository entirely, one the class author doesn't control.

Version 1.2.0 (commit) added a repeatable method attribute:

#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final class Environment
{
    public readonly array $names;

    public function __construct(string ...$names)
    {
        $this->names = array_values($names);
    }
}

Variadic, so one attribute can claim several environment names, and repeatable, so you can stack them:

use HpWebDeveloper\LaravelEnvSettings\Attributes\Environment;

#[Environment('production', 'prod')]
#[Environment('demo')]        // a second environment sharing production values
public static function production(): static { ... }

// The method name need not match the environment at all.
#[Environment('qa', 'uat')]
public static function qualityAssurance(): static { ... }

That last method is the part I like most, and it wasn't the goal — it fell out of the design. Once the environment name is stated explicitly, the method name is free. It can describe what the configuration is rather than which environment string happens to select it. The old design forced the method name to be the mapping key; the attribute separates identity from routing.

Resolution order became:

  1. A method marked #[Environment] for the current APP_ENV
  2. environment_map, then the APP_ENV value used as a method name
  3. fallback_environment, then development()

Attributes win, deliberately. The reasoning in the code comment is the whole argument: a class that states outright which environments it serves should not be silently redirected by a map it cannot see. The inverse precedence would have produced a class that lies about itself.

Everything below step 1 is untouched. A class with no attributes behaves exactly as it did before — which is the property that made the change shippable in a patch release rather than a major one.


7. Case Study Two: Replacing a Guess with a Declaration

The second problem was smaller and, in hindsight, more embarrassing. It also came first — this one shipped in v1.1.0, a few days before the environment work above.

The env-settings:show and env-settings:diff commands print resolved configuration to the console. Some of it shouldn't be printed.

Before — the decision was made by guessing at the property's name, somewhere else entirely:

// The settings class — nothing here says what is safe to print
public function __construct(
    public string $webhook_url,
    public string $passphrase,
    public string $monkey_api_url,
    public int $max_tokens,
) {}

// The command — a list of magic substrings the class never sees
private const SENSITIVE_NAME_FRAGMENTS = ['key', 'secret', 'password', 'token'];

Run it, and the output is wrong in both directions at once:

| webhook_url    | https://app.example.com/webhooks   |
| passphrase     | hunter2-actual-secret              |  ← printed. it matches nothing
| monkey_api_url | ********                           |  ← masked. contains "key"
| max_tokens     | ********                           |  ← masked. contains "token"

After — the property states its own status, and the output follows:

public function __construct(
    public string $webhook_url,
    #[Sensitive] public string $passphrase,
    public string $monkey_api_url,
    public int $max_tokens,
) {}
| webhook_url    | https://app.example.com/webhooks   |
| passphrase     | ********                           |
| monkey_api_url | https://monkey-api.example.com     |
| max_tokens     | 8000                               |

Three rows, two kinds of failure. monkey_api_url and max_tokens are false positives — harmless values hidden because their names happen to contain key and token. passphrase is a false negative: a real secret printed to stdout because it matches none of the four magic substrings. The false positives are annoying. The false negative is a security bug wearing a helpful hat.

The v1.1.0 commit introduced a marker attribute with no data at all:

#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER)]
final class Sensitive {}

An empty class. It carries no data and has no behaviour — its entire value is that it can be attached to something.

TARGET_PARAMETER alongside TARGET_PROPERTY is required rather than optional here — with constructor promotion, the declaration is syntactically a parameter, and property-only targeting would reject the most idiomatic way to write the class.

The masking logic then reads:

private function maskIfSensitive(ReflectionProperty $property, mixed $value, string $rendered): string
{
    if ($rendered === '') {
        return $rendered;
    }

    if ($property->getAttributes(Sensitive::class) !== []) {
        return self::MASK;
    }

    if (is_string($value) && $value !== '' && $this->nameLooksSensitive($property->getName())) {
        return self::MASK;
    }

    return $rendered;
}

Three decisions in there are worth pulling out, because they're the difference between a feature and a nuisance:

A marked property is masked whatever it holds. No type check, no heuristic second-guessing. The developer said so.

The old heuristic survives, but only for strings. Settings written before the attribute existed keep hiding what they used to hide, so nobody's secret got exposed by upgrading. Restricting it to strings fixes max_tokens — a number that happens to contain a flagged word is a number.

Empty values are left alone. Printing ******** for a value that was never set tells the reader something false. An empty string should look empty.

And the masking is display-only. toArray() still returns real values, and env-settings:diff compares the real values before masking them, so a sensitive property that differs between two environments is still reported as differing — without revealing either side. That distinction between is this value different and may I show this value is only expressible because the metadata is attached to the property rather than baked into the comparison.

Note what changed conceptually: nothing about the code's behaviour was previously derivable from the property. The old rule required the reader to know an undocumented list of magic substrings. The new one is visible on the line.


8. Implementation Details Worth Stealing

The design of both attributes took an afternoon. Making the reflection correct took longer, and these are the parts I'd want to know before writing the next one.

Cache, but be careful where

Attributes cannot change while the process runs, so each class needs reflecting exactly once:

/** @var array<class-string, array<string, string>> */
private array $environmentMethods = [];

Held on the instance, not statically. The resolver is a container singleton, so the cache lives and dies with the application — which means it doesn't leak between tests. A static array would have been marginally faster and would have made the test suite order-dependent. That trade is almost never worth taking.

Walk the hierarchy yourself

This is the bug I mentioned. PHP does not inherit attributes onto an overridden method. The package supports local override classes that extend a settings class and redeclare a factory — and a redeclared factory loses its #[Environment] mark, so resolution would silently fall through to development(). Silently, because a missing attribute is not an error.

The fix walks the chain explicitly:

for ($current = new ReflectionClass($class); $current !== false; $current = $current->getParentClass()) {
    foreach ($current->getMethods(ReflectionMethod::IS_PUBLIC | ReflectionMethod::IS_STATIC) as $method) {
        // Skip methods inherited into $current; each class in the chain
        // contributes only what it declares itself.
        if ($method->getDeclaringClass()->getName() !== $current->getName()) {
            continue;
        }

        if (! $method->isPublic() || ! $method->isStatic()) {
            continue;
        }

        foreach ($method->getAttributes(Environment::class) as $attribute) {
            foreach ($attribute->newInstance()->names as $name) {
                $methods[$name] ??= $method->getName();
            }
        }
    }
}

Only the mapping is inherited. The method is still called on the subclass, so an override's own values are the ones used — you inherit the routing, not the data.

getMethods() filters with OR, not AND

Look at the redundant-seeming check inside that loop. getMethods(ReflectionMethod::IS_PUBLIC | ReflectionMethod::IS_STATIC) does not return methods that are public and static. It returns methods that are public or static — so public instance methods and private static ones both come through. Calling either as a factory raises an Error, so the explicit isPublic() && isStatic() check is doing real work.

This is a genuine reflection footgun, it is not obvious from the method name, and it isn't specific to attributes — anyone filtering methods by modifier has it.

Make conflicts deterministic, not fatal

$methods[$name] ??= $method->getName();

Two methods claiming the same environment is a mistake, but throwing at resolution time means an application that boots fine in CI can fail in production on a config nobody exercised. First declaration wins, and because the hierarchy is walked from the subclass upward, declarations closest to the class win over inherited ones. Predictable beats strict when the failure would happen at runtime in someone else's application.


9. When Not to Reach for an Attribute

Attributes are new enough that the pendulum tends to swing. Not every fact belongs on a declaration.

Anything that varies per installation. If two deployments of the same code need different values, it is configuration, and it belongs in config or the environment. An attribute is compiled into the class — changing it is a code change. This is exactly the line the package draws for its own settings, and the same reasoning applies to metadata.

Anything that needs a runtime value. Constant expressions only. If you find yourself wanting a function call in an attribute argument, the design is wrong somewhere else.

Anything global. A middleware stack, a service map, a list of registered classes — those are properties of the application, not of any one class. Putting them on classes scatters a single decision across a codebase and makes it impossible to read as a whole.

Anything a reader shouldn't have to know about. Every attribute is vocabulary a new contributor has to learn. Two well-chosen ones are an improvement; fifteen bespoke ones are a framework nobody documented.

The test I'd use: is this fact true of the code, or true of this deployment of the code? Attributes are for the first. Config is for the second. The #[Environment] case passes because "this factory serves production" is a property of the method — it stays true no matter where the app runs. The values inside that factory are per-environment data, which is why they live in code the resolver selects, not in the attribute.


10. The Pattern Underneath

Both commits are the same move, made twice.

In the first, a fact about a method — which environments it serves — lived in a config array in another file. In the second, a fact about a property — whether it can be shown — lived in an undocumented list of substrings in the reader's head. Neither fact was ever really configuration. Both were properties of the code, stored away from the code because PHP hadn't offered a way to keep them there.

Both changes also kept the old mechanism as a fallback, which I'd recommend to anyone doing this to a published package. The heuristic still runs for unmarked string properties; environment_map still resolves classes with no attributes. Adoption is opt-in, per declaration, and nobody's upgrade broke. Attributes are additive by nature — an unmarked declaration is just a declaration — and that makes them unusually safe to retrofit.

If you want a single sentence for when to use them: an attribute is the right tool when the code and the fact about the code should never be able to disagree. Config files can drift from what they describe. Naming conventions can be violated silently. An attribute is attached to the declaration and travels with it into every application that installs your package.

The feature is small. What it fixes isn't.


If you want the wider context for the package these examples come from, that's in my earlier article on laravel-env-settings, and the code is on GitHub.