DEV Community

Cover image for Attributes on Constants in PHP 8.5: Metadata You Couldn't Attach Before
Gabriel Anhaia
Gabriel Anhaia

Posted on

Attributes on Constants in PHP 8.5: Metadata You Couldn't Attach Before


You know the config file. A wall of const declarations, each one trailed by a // comment that explains what it does, which environment variable overrides it, and whether it's safe to touch. The comment is the only documentation. No tool reads it. No validator checks it. Someone renames the constant six months later and the comment rots in place, still describing the old name.

PHP 8.5 shipped in November 2025 with a small change that fixes this. You can now put attributes on global constants declared with const. The metadata that used to live in a comment can now live in a structure your code can read back with reflection.

The gap that closed in 8.5

Attributes arrived in PHP 8.0. From day one they could target classes, methods, functions, properties, parameters, and class constants. That last one matters: #[SomeAttribute] public const FOO = 1; inside a class has worked for years through ReflectionClassConstant::getAttributes(). Most teams never wired it up, but the door was open.

The door that stayed shut was global constants. This never parsed:

#[SomeAttribute]
const MAX_UPLOAD_MB = 25;   // parse error before 8.5
Enter fullscreen mode Exit fullscreen mode

PHP 8.5 opens it. The engine adds a new attribute target, Attribute::TARGET_CONSTANT, distinct from the older Attribute::TARGET_CLASS_CONSTANT. And ReflectionConstant (the class added in 8.4 for inspecting global constants) gains a getAttributes() method so you can read them back.

So the headline is narrow but real: class constants already carried metadata; global constants finally can too.

A config file that documents itself

Start with an attribute that describes a setting. One summary line, one optional env-var override name.

#[Attribute(Attribute::TARGET_CONSTANT)]
final class ConfigDoc
{
    public function __construct(
        public string $summary,
        public ?string $env = null,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

Now the config constants carry their own documentation:

namespace App\Config;

#[ConfigDoc(
    summary: 'Max upload size in megabytes.',
    env: 'MAX_UPLOAD_MB',
)]
const MAX_UPLOAD_MB = 25;

#[ConfigDoc(
    summary: 'Seconds before an idle session expires.',
    env: 'SESSION_TTL',
)]
const SESSION_TTL = 1800;
Enter fullscreen mode Exit fullscreen mode

The value and its meaning sit in one place. Nothing in a sidecar comment, nothing in a separate config-schema.php that drifts out of sync. Rename the constant and the metadata moves with it, because it's part of the declaration.

Reading them back with ReflectionConstant

The point of metadata is that something reads it. Here a small script walks a list of constants and prints a documentation table.

$names = [
    'App\\Config\\MAX_UPLOAD_MB',
    'App\\Config\\SESSION_TTL',
];

foreach ($names as $name) {
    $ref   = new ReflectionConstant($name);
    $attrs = $ref->getAttributes(ConfigDoc::class);

    if ($attrs === []) {
        continue;
    }

    $doc = $attrs[0]->newInstance();
    printf(
        "%s = %s\n  %s (env: %s)\n",
        $ref->getShortName(),
        var_export($ref->getValue(), true),
        $doc->summary,
        $doc->env ?? 'none',
    );
}
Enter fullscreen mode Exit fullscreen mode

Output:

MAX_UPLOAD_MB = 25
  Max upload size in megabytes. (env: MAX_UPLOAD_MB)
SESSION_TTL = 1800
  Seconds before an idle session expires. (env: SESSION_TTL)
Enter fullscreen mode Exit fullscreen mode

getAttributes(ConfigDoc::class) filters to the one attribute you care about. newInstance() builds the attribute object, so $doc->env is a typed string, not a raw array. From here you're a few lines away from a real feature: a config:doc console command, a check that every documented env name actually exists in the environment, a page in your admin panel that lists tunables and what they do.

Tagging enum-like class constants

Plenty of codebases still model flags and small closed sets as integer class constants rather than enums, especially bitmask permissions where the numeric value carries meaning. Those constants have been able to take attributes since 8.0, but almost nobody reads them. Same idea, one attribute for a human-readable label:

#[Attribute(Attribute::TARGET_CLASS_CONSTANT)]
final class Label
{
    public function __construct(
        public string $text,
    ) {}
}

final class Permission
{
    #[Label('Read records')]
    public const int READ = 1;

    #[Label('Create and edit records')]
    public const int WRITE = 2;

    #[Label('Delete records')]
    public const int DELETE = 4;
}
Enter fullscreen mode Exit fullscreen mode

Reading these goes through ReflectionClass, which hands back a ReflectionClassConstant per constant:

$ref = new ReflectionClass(Permission::class);

foreach ($ref->getReflectionConstants() as $const) {
    $attrs = $const->getAttributes(Label::class);

    if ($attrs === []) {
        continue;
    }

    printf(
        "%-7s => %s\n",
        $const->getName(),
        $attrs[0]->newInstance()->text,
    );
}
Enter fullscreen mode Exit fullscreen mode

Output:

READ    => Read records
WRITE   => Create and edit records
DELETE  => Delete records
Enter fullscreen mode Exit fullscreen mode

That's a permission-picker UI built from the type itself. Add a fourth permission with its own #[Label] and the list grows with no second edit anywhere. The label lives next to the value it describes, which is exactly where a reviewer expects to find it.

When an enum is the right model, reach for an enum; its cases have taken attributes since 8.1. But for the legacy bitmask constants you can't migrate this quarter, the same reflection pattern applies without a rewrite.

Deprecating a constant the language understands

PHP 8.4 added the built-in #[\Deprecated] attribute for functions and methods. PHP 8.5 extends it to constants. Instead of a comment nobody sees, you mark the old name and the engine tracks it.

#[\Deprecated(
    message: 'renamed to App\\Config\\SESSION_TTL',
    since: '2.4.0',
)]
const SESSION_TIMEOUT = 1800;
Enter fullscreen mode Exit fullscreen mode

Two things follow. Referencing SESSION_TIMEOUT at runtime raises an E_USER_DEPRECATED diagnostic carrying your message, so the warning shows up in logs and in test runs instead of being silently ignored. And the state is inspectable:

$ref = new ReflectionConstant('SESSION_TIMEOUT');
var_dump($ref->isDeprecated());
// bool(true)
Enter fullscreen mode Exit fullscreen mode

A static-analysis step or a CI check can now fail the build on any use of a deprecated constant. The deprecation is data, not prose.

The edges: what still won't take an attribute

A few limits are worth knowing before you go tagging everything.

Constants defined at runtime with define() can't carry attributes. The feature is compile-time only, so it's const or nothing.

Grouped declarations are rejected. One constant per statement:

#[ConfigDoc(summary: 'Fine.')]
const A = 1;

#[ConfigDoc(summary: 'Parse error.')]
const B = 2, C = 3;   // attribute on a group is not allowed
Enter fullscreen mode Exit fullscreen mode

If you want attributes on B and C, split them into separate const lines. That's a reasonable nudge anyway; grouped constants share a comment and confuse tooling.

And getAttributes() gives you reflection objects, not validation. Nothing forces a constant to have a ConfigDoc, and nothing checks that the env you named exists. That check is yours to write. The attribute is the hook; the guarantee is the small script you run in CI.

Where this lands

Attributes on constants won't restructure an application. What they do is pull one more piece of meaning out of comments and into something the language can read. Config that describes itself, closed sets that expose their own labels, deprecations the engine enforces instead of trusting you to notice. Small surface, steady payoff.

If you already lean on attributes for routing, validation, or serialization, this closes an annoying gap: the one kind of declaration that couldn't join the party now can.


If this was useful

Metadata belongs next to the thing it describes, and it belongs at the edge, read by a thin layer that turns it into behavior instead of scattering that behavior across the codebase. That boundary between plain declarations in the domain and the reflection glue that consumes them is the same line clean and hexagonal architecture draws everywhere. If keeping your framework and tooling concerns at the edge of the application is the problem you're chewing on, that's what Decoupled PHP is about.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)