PHP 8 attribute quirks

Saturday, September 17, 2022

A quick post to document some specific behaviours I encountered with PHP 8's Attributes that aren't in the official docs.

1. Inheriting from an attribute does not make you an attribute

// This is an attribute
 
#[Attribute]
class ParentAttribute {}

#[ParentAttribute]
function functionWithParent() {
}

// But this isn't!

class ChildAttribute extends ParentAttribute {}

// So this has no effect...

#[ChildAttribute]
function functionWithChild() {
}

We can prove this by trying to instantiate the attributes using the Reflection API:

$reflectionFunction = new ReflectionFunction('functionWithParent');
$attributes = $reflectionFunction->getAttributes();
print_r(array_map(fn ($a) => $a->newInstance(), $attributes));

$reflectionFunction = new ReflectionFunction('functionWithChild');
$attributes = $reflectionFunction->getAttributes();
print_r(array_map(fn ($a) => $a->newInstance(), $attributes));

This prints:

Array
(
    [0] => ParentAttribute Object
        (
        )

)

Fatal error: Uncaught Error: Attempting to use non-attribute class "ChildAttribute" as attribute

To make ChildAttribute an attribute itself, you'll need to mark it with the Attribute attribute, even though it's already on its parent. 🤷‍♀️

#[Attribute]
class ChildAttribute extends ParentAttribute {}

But why? I'm not sure, but I think there's a good argument that explicitly labelling each class with the specific attributes it has makes its behaviour obvious. Also, having attribute data be inherited by child classes might be undesirable. It would mean that, for example:

#[SomeAttributeMarker]
class SomeClass {}

class SomeOtherClass extends SomeClass {}

SomeOtherClass would be treated as if it had SomeAttributeMarker applied to it, which may or may not be what you want. Even worse, what happens with inherited methods? Do they inherit attributes too?

class SomeClass 
{
  #[SomeAttributeMarker]
  public function m() {}
}

class SomeOtherClass extends SomeClass
{
  public function m() {}
}

Does m in the child inherit the attributes from m in the parent? What about if it's protected, or private? You could say to follow the normal rules of class inheritance, but with complex object trees, those can get tricky pretty quickly, and it's probably not worth it for a feature that's meant to be purely static (more on that later).

But ultimately: manually adding a new attribute is easy, but removing an automatically inherited one would be complicated. I think it was the right call by the devs.

2. Inheriting from a class with an attribute doesn't inherit its attributes

I had this listed as my second point, but I've realized it's completely covered by the explanation in the first. Leaving it here just because. 😐

3. Any class can be an attribute

At first, this sounds contradictory to our earlier discovery. But here's what I mean. This code compiles (and executes) fine...

#[stdClass]
#[SomeNonexistentClass]
#[SomeNonexistentClass("you can even pass in arguments")]
function fnWithAttributes() {
}

...despite the fact that stdClass is a default PHP class that is not marked as an attribute, and SomeNonexistentClass does not exist.

What happens when we try to access the attributes via reflection? Well, depending on what you're doing, it still works!

$reflectionFunction = new ReflectionFunction('fnWithAttributes');
$attributes = $reflectionFunction->getAttributes();
array_map(fn ($a) => [$a->getName(), $a->getArguments()], $attributes);
[
  ["stdClass", []],
  ["SomeNonexistentClass", []],
  ["SomeNonexistentClass", ["you can even pass in arguments"]],
]

We're still good! The problems finally surface when we try to instantiate them:

array_map(function ($a) {
  try {
    return $a->newInstance();
  } catch (Throwable $e) {
    return $e->getMessage();
  }
}, $attributes);
[
  "Attempting to use non-attribute class "stdClass" as attribute",
  "Attribute class "SomeNonexistentClass" not found",
  "Attribute class "SomeNonexistentClass" not found",
]

So why did PHP let us get this far? The first reason is that the atttribute syntax re-purposes an old PHP syntax, so it had to be non-breaking. PHP's commenting style is //, but it also supports #, so when attributes were introduced, they needed to do nothing by default; otherwise, if the compiler tried to validate that the attribute class actually existed, older PHP code might start crashing.

A more significant reason is that attributes are purely static code. They are meant to be used as markers or holders of metadata for other entities like functions and objects, so they do nothing until some consumer who's interested in that metadata actively requests it.

A third (and definitely the main) reason is that attributes aren't required to be classes. I thought they were, but while writing this, I noticed the doc says it (literally the first sentence): "While not strictly required it is recommended to create an actual class for every attribute." I'd read that before but didn't understand it.😅 Now I realize what it means: you can use anything as an attribute. It doesn't have to be a class, as long as the consumer isn't trying to instantiate it. So the first set of examples was fine.

In essence, attributes are a sort of standardized format for specifying metadata, along with inbuilt parsing via the Reflection API. So packages no longer need to define their own custom formats and parsers for docblock tags. They can simply use attributes, even if they aren't actual classes. Cool!

4. By default, reflection does not return attribute subclasses

The final thing I "discovered" (which, it turns out, is actually documented) is that the Reflection API's getAttributes() method, when given the name of an attribute class, will only return attributes matching that class name. So, in this case:

#[ChildAttribute]
function functionWithChild() {
}


$reflectionFunction = new ReflectionFunction('functionWithChild');
$attributes = $reflectionFunction->getAttributes(ParentAttribute::class);
print_r(array_map(fn ($a) => $a->getName(), $attributes));

...we always get an empty array, since the function doesn't have any ParentAttribute attributes.

But it turns out you can pass an argument that will tell it to include subclasses.

#[ChildAttribute]
function functionWithChild() {
}


$reflectionFunction = new ReflectionFunction('functionWithChild');
$attributes = $reflectionFunction->getAttributes(
  ParentAttribute::class, ReflectionAttribute::IS_INSTANCEOF
);
print_r(array_map(fn ($a) => $a->getName(), $attributes));

With this, you get ["ChildAttribute"]. And this works whether or not the ChildAttribute is marked with #[Attribute] or not. Don't ask me why.


All in all, I'm pretty impressed. I came in expecting to find bugs or UX gaps, but digging through this stuff has shown me how much thought the devs put into this feature.


Hey👋. I write about interesting software engineering challenges. Want to get updated when I publish new posts? Just visit tntcl.app/blog.shalvah.me.

(Confession: I built Tentacle.✋ It helps you keep a clean inbox by combining your favourite blogs into one weekly newsletter.)

Powered By Swish