PHP 8.0 feature focus: Attributes

Larry Garfield
Larry Garfield
Director of Developer Experience
12 Nov 2020

Metaprogramming gets more meta in PHP 8.0.

Last time, we looked at one of the three headline features of PHP 8.0: Constructor Property Promotion. Today, we’ll look at the second: Attributes.

A history of change

Some PHP changes are proposed, discussed, implemented, and approved in short order. They’re uncontroversial, popular, and have a natural way to implement them.

And then there are the ones that are tried, fail, and come back multiple times before they are finally accepted. Sometimes the implementation takes a long time to sort out, sometimes the idea itself is only half-baked, and sometimes the community just hasn’t warmed up to an idea yet—“it’s not yet time.”

Attributes fall squarely into the latter category. They were first proposed back in 2016, for PHP 7.1, but met with stubborn resistance and soundly lost the acceptance vote. Fast forward four years and a very similar if slightly reduced-scope proposal sailed through with only one dissenter. It’s apparently an idea whose time has indeed come.

Getting meta

So what are attributes? Attributes are declarative metadata that can be attached to other parts of the language and then analyzed at runtime to control behavior.

If you’ve ever used Doctrine Annotations, attributes are essentially that, but as a first-class citizen in the language syntax. The terms “attributes” and “annotations” are used roughly interchangeably in the various languages that support them. PHP went with the term “attribute” specifically to reduce confusion with Doctrine Annotations, since they have a different syntax. Because attributes are built into the language, though, rather than being simply a user-space documentation convention, they can be linted and type checked by static analyzers, IDEs, and syntax highlighters.

The specific syntax was the subject of far more debate than the feature itself, but I’m going to skip over all of that and just look at the final result. Attributes in PHP 8.0 borrow their syntax from Rust:

<?php

#[GreedyLoad]
class Product
{

    #[Positive]
    protected int $id;

    #[Admin]
    public function refillStock(int $quantity): bool
    {
        // ...
    }
}

Those various #[…] blocks are attributes. At runtime, they do … absolutely nothing. They have no impact on the code itself. However, they are available to the reflection API, which allows other code to examine the Product class, or its properties and methods, and take additional action. What that action is, well, that’s up to you.

As a side effect, attributes are interpreted as comments in older PHP versions and thus ignored, if they’re single-line. That’s more a useful side effect than a deliberate feature, but is worth noting. It’s probably rendering as a comment in your browser, too. It will take a little while for syntax highlighters and IDEs to catch up, but expect that to happen soon enough.

An important note about attributes is that they’re not limited to strings. In fact, in the overwhelming majority of cases they will be objects, which can take parameters.

Attributes can be attached to classes, properties, methods, functions, class constants, and even to function/method parameters. That’s even more places than Doctrine annotations.

A practical example

All of this seems rather abstract, so let’s take a practical example. Most uses of attributes/annotations in PHP today revolve around registration. That is, they’re a declarative way to tell one system details of another system, such as a plugin or event system. To that end, I have updated my PSR-14 Event Dispatcher implementation, Tukio, to support attributes. Here’s how it works (somewhat simplified).

Tukio provides a “listener provider” that aggregates and orders “listeners,” that is, any type of callable to which an event can be passed. Normally, you can register a listener like so:

<?php

function my_listener(MyEventType $event): void { ... }

$provider = new OrderedListenerProvider();

$provider->addListener('my_listener', 5, 'listener_a', MyEventType::class);

Those parameters are the callable to add (in this case the function name) and then optionally a priority for ordering, a custom ID, and the type of event to listen to. The latter two are generally derived automatically, but I’m including them here to demonstrate that you can specify them on the fly. There are also methods to add listeners before or after another listener, based on its ID.

Specifying all of those on the fly is a pain, though. It would be especially nice to specify the ID along with the listener, for instance, so it’s consistent. Attributes allow us to do that.

First, we define our new attribute. Attributes are a normal PHP class that themselves have a special attribute:

<?php

namespace Crell\Tukio;

use \Attribute;

#[Attribute]
class Listener implements ListenerAttribute
{
   public function __construct(
       public ?string $id = null,
       public ?string $type = null,
   ) {}
}

The #[Attribute] attribute tells PHP “yes, this class can be loaded as an attribute.” Also note that, as attribute is itself a class, it is subject to namespace rules and needs to be used at the top of the file.

The class then also defines a constructor … using the new constructor promotion syntax. This is strictly an internal data class, so we’ll just give it two optional public properties that are populated from the constructor. (In PHP 7.4, the same code would be twice as long.)

Let’s go a step further; it doesn’t make any sense to put a Listener attribute on a parameter or a class, just on functions and methods. We can therefore tell the class that it’s a valid attribute only on functions and methods, using a series of bit flags:

<?php

namespace Crell\Tukio;

use \Attribute;

#[Attribute(Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD)]
class Listener implements ListenerAttribute
{
   public function __construct(
       public ?string $id = null,
       public ?string $type = null,
   ) {}
}

Now, if we try to put a Listener attribute on a not-function, not-method, it will throw an error when we try to use it.

That also suggests how we’ll use the Listener attribute to pass in parameters.

<?php

use Crell\Tukio\Listener;

#[Listener('listener_a')]
function my_listener(MyEventType $event): void { ... }

$provider = new OrderedListenerProvider();

$provider->addListener('my_listener', 5);

By itself that does nothing. But once given the name of the function, we can use the reflection API to pull out that information. A (very) simplified version of what Tukio does here is:

<?php

Use Crell\Tukio\Listener;

/// A string means it's a function name, so reflect on it as a function.
if (is_string($listener)) {
    $ref = new \ReflectionFunction($listener);
    $attribs = $ref->getAttributes(Listener::class, \ReflectionAttribute::IS_INSTANCEOF);
    $attributes = array_map(fn(\ReflectionAttribute $attrib) => $attrib->newInstance(), $attribs);
}

In this example, $attribs is an array of \ReflectionAttribute objects. There are a few things you can do with one of those. In particular, the attribute doesn’t have to be a class! You can get just the name and arguments (if any) as a string and array, respectively. That can be helpful for static analysis or for simple flag attributes whose existence already tells you everything you need to know.

The getAttributes() method can also filter the attributes on a value for you. In this case, we’re limiting it to only return Listener attributes and to allow subclasses of Listener to be included as well. While optional, I strongly recommend doing both as it naturally filters out attributes from other libraries you may not expect, and allowing subclasses or implements means you can cluster a series of attributes together using an interface.

The last line maps over that array and calls newInstance() on each attribute. That directly calls the Listener constructor with the parameters specified, then returns the result. The object that comes back is identical to what you would get had you just written new Listener('listener_a'). Beyond that constructor call the attribute class can do or have anything any other class can do or have. You could give it complex internal logic or not, handle default values, make certain parameters required, etc., as your situation requires.

Now, Tukio can combine the data from the addListener() method call and the Listener attribute however it wants. In this case, the effect is the same as if you had specified the ID in the method call rather than in the attribute.

A single language item can be tagged with multiple attributes, even attributes from entirely different libraries. Attributes can also allow themselves to be repeated on a single language item or not. Sometimes it may make sense to allow the same attribute twice, other times not. Both can be enforced.

There’s a lot more that you can do, and Tukio actually has multiple attributes it supports for different use cases and situations that I won’t go into here for brevity, but that should give you a taste of what’s possible.

Coming soon to a framework near you

Attribute support is already appearing in frameworks. The upcoming Symfony 5.2 is going to include attribute versions of route definition, for instance. That means you’ll be able to declare a controller and wire it up to a route like so:

<?php

use Symfony\Component\Routing\Annotation\Route;

class SomeController
{
    #[Route('/path', 'action_name')]
    public function someAction()
    {
        // ...
    }
}

For the most part, that’s just swapping Doctrine annotations syntax for native attributes to achieve the same goal. However, it’s also going a step further. You can even control what dependencies get passed to your controller parameters via attributes on the parameters, something Doctrine couldn’t do.

<?php

namespace App\Controller;

use App\Entity\MyUser;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Security\Http\Attribute\CurrentUser;

class SomeController extends AbstractController
{
    public function index(#[CurrentUser] MyUser $user)
    {
        // ...
    }
}

That tells the argument resolvers to pass the current user, specifically, to the $user parameter.

No doubt we’ll see more use of attributes in other frameworks and libraries as well now that it has first-class native support in the language.

It’s also important to note how attributes work on promoted constructor arguments. Recall that in PHP 8.0, it’s now possible to specify an object property and constructor argument at the same time when one just maps to the other, like so:

<?php

class Point
{
    public function __construct(public int $x, public int $y) {}
}

Both arguments and properties can have attributes, however. So in this example, does the attribute refer to the argument or the property?

<?php

class Point
{
    public function __construct(
        #[Positive] 
        public int $x,
        #[Positive] 
        public int $y,
    ) {}
}

Technically it could be either, but it may not always make sense for one or the other. The engine can’t know which one it’s supposed to be.

For 8.0, the decision was to do both. The attribute will be available on both the property and the argument. If that doesn’t make sense for your application, either filter it out in your own application logic or don’t use constructor promotion for that one parameter, so you can attribute it in just one place and not the other.

Native attributes

In 8.0, there are no attributes that mean anything to the PHP engine, aside from the \Attribute class itself. However, it’s unlikely to stay that way. The RFC called out several possible future attributes that could have meaning to the engine in the future.

For example, a #[Memoize] attribute could tell the engine that a function or method is safe to cache, and the engine could then automatically cache the results of that function. Another option would be a #[Jit] attribute that could provide hints to the JIT engine that a given function or class is a good or bad candidate for JIT compilation or tweak how it gets compiled. Perhaps even an #[Inline] attribute that could tell the compiler it should try to inline a function call, thus saving time on the call itself.

None of these engine attributes exist yet, but they’re the sort of ideas that are now available to engine developers to further enhance the language in future versions.

A long list of credits

Attributes were developed and modified over the course of a couple of RFCs.

The original RFC comes courtesy of Benjamin Eberlei and Martin Schröder and used a different syntax borrowed from older versions of Hack. A follow-up RFC from the same people tweaked the features a bit.

The syntax was changed twice after that, in RFCs from Theodore Brown and Martin Schröder and then from Derick Rethans and Benjamin Eberlei to settle on the final syntax.

Stay tuned

Next week we’ll cover the last of the big-three features that will change the PHP world in 8.0. Just as a teaser, the Symfony samples above I modified from the announcement blog post. The actual syntax they show looks like this:

<?php

class SomeController
{
    #[Route('/path', name: 'action')]
    public function someAction()
    {
        // ...
    }
}

What’s going on there in that attribute? Stay tuned …

You can try out pre-release copies of PHP 8.0 today on Platform.sh, with just a one-line change. Give it a whirl, and let us know what your favorite features are.