In this article
Introduction
When building web applications in PHP, there may be times when you want to add metadata to annotate your code that can be read by other parts of your application. For example, if you've ever used PHPUnit to write tests for your PHP code, you'll have likely used the @test
annotation to mark a method as a test in a DocBlock.
Traditionally, annotations like this have been added to code using DocBlocks. However, as of PHP 8.0, you can use attributes instead to annotate your code in a more structured way.
In this article, we'll learn what attributes are, what their purpose in your code is, and how to use them. We'll also learn how to create your own attributes and how to test code that uses attributes.
By the end of this article, you should feel confident enough to start using attributes in your own PHP applications.
What are Attributes?
Attributes were first added to PHP in PHP 8.0 (released in November 2020). They enable you to add machine-readable, structured metadata to your code. They can be applied to classes, class methods, functions, anonymous functions, properties, and constants. They follow the syntax of #[AttributeNameHere]
, where AttributeNameHere
is the name of the attribute. For example: #[Test]
, #[TestWith('data')]
, #[Override]
, and so on.
To understand what they are and the problem they solve, it's useful to compare them to "tags" in DocBlocks. So let's first start by taking a look at an example of a PHPUnit test that uses a DocBlock:
1/** 2 * @test 3 * @dataProvider permissionsProvider 4 */ 5public function access_is_denied_if_the_user_does_not_have_permission( 6 string $permission 7): void { 8 // Test goes here... 9}10 11public static function permissionsProvider(): array12{13 return [14 ['superadmin'],15 ['admin'],16 ['user'],17 // and so on...18 ];19}
In the code above, we've created two methods:
-
access_is_denied_if_the_user_does_not_have_permission
- This is the actual test method itself. It accepts a$permission
parameter, and we'll assume it asserts that access is denied if the user does not have the given permission. -
permissionsProvider
- This is a data provider method. It returns an array of strings that are passed to the test method as the$permission
parameter.
By applying the @test
tag in the DocBlock on the test method, PHPUnit will recognize this method as a test that can be run. Similarly, by applying the @dataProvider
tag in the DocBlock on the test method, PHPUnit will use the permissionsProvider
method as a data provider for the test method.
Due to the fact that the permissionsProvider
data provider method returns an array with three items, this means the test will be run three times (once for each of the permissions).
Although tags in DocBlocks can be helpful to annotate your code, they are limited in the sense that they are just plain text. As a result, this means they can lack structure and can be easy to get wrong. For example, let's purposely add a typo to the @test
tag in the DocBlock:
1/**2 * @tests3 * @dataProvider permissionsProvider4 */5public function access_is_denied_if_the_user_does_not_have_permission(6 string $permission7): void {8 // Test goes here...9}
In the code example above, we've renamed @test
to @tests
. If you were to look in a test class containing a lot of tests, you likely wouldn't spot that typo. If we attempted to run this test, it wouldn't run because PHPUnit wouldn't recognize it as being a test.
This is where attributes can come in handy. Sticking with our PHPUnit example, let's update our test method to use attributes rather than a DocBlock. As of PHPUnit 10, you can use the #[\PHPUnit\Framework\Attributes\Test]
attribute to mark a method as a test. You can also use the #[\PHPUnit\Framework\Attributes\DataProvider()]
attribute to specify a method as a data provider for the test. The updated code would look like this:
1use PHPUnit\Framework\Attributes\Test; 2use PHPUnit\Framework\Attributes\DataProvider; 3 4#[Test] 5#[DataProvider('permissionsProvider')] 6public function access_is_denied_if_the_user_does_not_have_permission( 7 string $permission 8): void { 9 // Test goes here...10}
As we can see in the code example above, we've added two attributes to the test method:
-
#[Test]
- This attribute marks the method as a test. -
#[DataProvider('permissionsProvider')]
- This attribute specifies thepermissionsProvider
method as being the data provider for the test method.
You may have also noticed that these attributes are both namespaced and imported in the same way you would import a class. This is because attributes are actually classes themselves; we'll discuss this in more detail later in the article.
Now if the #[Test]
attribute was to be renamed to #[Tests]
, this would be recognized as an error in your IDE and highlighted (typically with a red underline) so you can easily spot and fix it. This is because PHP would attempt to find a Tests
attribute within the current namespace. For instance, if we imagine the test is located in the Tests\Feature\Authorization
namespace, PHP would attempt to resolve a Tests\Feature\Authorization\Tests
attribute. However, in this case, we'll assume the #[Tests]
attribute doesn't exist. This is similar to what happens if you try to reference a class in your code that doesn't exist. However, whereas trying to use a class that doesn't exist would result in an exception being thrown at runtime, trying to use an attribute that doesn't exist will not. So the test still won't run (similar to DocBlocks), but your IDE will be able to highlight the issue for you to solve it more easily.
It's important to remember that unless you explicitly validate the attributes in your code at runtime, PHP won't throw an exception if an attribute doesn't exist.
Another useful feature of attributes is that they can accept arguments (as shown with the #[DataProvider]
attribute in the example above). Due to the structured nature of attributes, your IDE will typically be able to show autocomplete suggestions for the arguments that the attribute accepts. This can be useful if you're not sure what arguments an attribute accepts.
How to Use Attributes
Now that we've got a brief overview of what attributes are, let's look at how we can use them in our code.
Applying Attributes to Code
As we've already mentioned, attributes can be applied to classes, class methods, functions, anonymous functions, properties, and constants. Let's take a look at how this might look by using an example #[MyAwesomeAttribute]
attribute.
You can apply an attribute to a class by adding it directly above the class declaration:
1#[MyAwesomeAttribute]2class MyClass3{4 // Class code goes here...5}
You can apply an attribute to a class method by adding it directly above the method declaration:
1class MyClass2{3 #[MyAwesomeAttribute]4 public function myMethod()5 {6 // Method code goes here...7 }8}
You can apply an attribute to a function by adding it directly above the function declaration:
1#[MyAwesomeAttribute]2function myFunction()3{4 // Function code goes here...5}
You can apply an attribute to an anonymous function by adding it directly before the anonymous function declaration:
1$myFunction = #[MyAwesomeAttribute] function () {2 // Anonymous function code goes here...3};
You can apply an attribute to a property by adding it directly above the property declaration:
1class MyClass2{3 #[MyAwesomeAttribute]4 public string $myProperty;5}
You can apply an attribute to a constant by adding it directly above the constant declaration:
1class MyClass2{3 #[MyAwesomeAttribute]4 public const MY_CONSTANT = 'my-constant';5}
You can also apply an attribute to function arguments. For example, as of PHP 8.2, you can use the PHP's #[\SensitiveParameter]
attribute to prevent the value passed to a function argument from being available or logged in the stacktrace. This can be useful if you have a method that accepts a field such as a password and don't want the value to be logged in the stacktrace. You can apply the attribute to the function argument like this:
1function authenticate(2 string $username,3 #[\SensitiveParameter] string $password,4) {5 // Function code goes here...6}
Passing Parameters to Attributes
As we've already seen above, it's also possible to pass parameters to attributes so that they can be read by the code that uses them. Sticking with our PHPUnit example from earlier, we saw that we could pass the name of the data provider method to the #[DataProvider]
attribute:
1use PHPUnit\Framework\Attributes\Test; 2use PHPUnit\Framework\Attributes\DataProvider; 3 4#[Test] 5#[DataProvider('permissionsProvider')] 6public function access_is_denied_if_the_user_does_not_have_permission( 7 string $permission 8): void { 9 // Test goes here...10}
Just like with functions in PHP, attributes also support using named arguments. We can rewrite the code example above to use named arguments like this:
1use PHPUnit\Framework\Attributes\Test; 2use PHPUnit\Framework\Attributes\DataProvider; 3 4#[Test] 5#[DataProvider(method: 'permissionsProvider')] 6public function access_is_denied_if_the_user_does_not_have_permission( 7 string $permission 8): void { 9 // Test goes here...10}
As we can see in the example above, we're using the method:
named argument. As a result, this means you can improve the readability of your code if it's not clear what the argument is for.
It's also possible to pass multiple arguments to an attribute if it expects them. For example, we could remove the #[DataProvider]
attribute from our test and hardcode the values using the #[TestWith]
attribute instead:
1use PHPUnit\Framework\Attributes\Test; 2use PHPUnit\Framework\Attributes\TestWith; 3 4#[Test] 5#[TestWith('superadmin', 'admin', 'user')] 6public function access_is_denied_if_the_user_does_not_have_permission( 7 string $permission 8): void { 9 // Test goes here...10}
Using Multiple Attributes at Once
If you're using multiple attributes on the same target (i.e., class, method, function, etc.), you can either define them all inside the same #[...]
block or use a separate block for each attribute.
To apply the attributes within separate #[...]
blocks, you can do this:
1use PHPUnit\Framework\Attributes\Test; 2use PHPUnit\Framework\Attributes\TestWith; 3 4#[Test] 5#[TestWith('superadmin', 'admin', 'user')] 6public function access_is_denied_if_the_user_does_not_have_permission( 7 string $permission 8): 9{10 // Test goes here...11}
To apply the attributes within the same #[...]
block, you can do this:
1use PHPUnit\Framework\Attributes\Test; 2use PHPUnit\Framework\Attributes\TestWith; 3 4#[ 5 Test, 6 TestWith('superadmin', 'admin', 'user') 7] 8public function access_is_denied_if_the_user_does_not_have_permission( 9 string $permission10):11{12 // Test goes here...13}
How to Create Your Own Attributes
Now that we've learned about how attributes can be applied, let's take a look at how we can create our own attributes and then read them in our code.
We'll use attributes to add human-friendly labels to the cases in an enum. We'll imagine that these labels will be used when displaying the enum cases to the user (for example, in a dropdown menu on a form).
Let's say we're building a blogging platform, and we have a PostStatus
enum that represents the status of a blog post. There may be places where we want to display the blog post's PostStatus
enum case to the user. Let's take a look at the enum:
app/Enums/PostStatus.php
1namespace App\Enums;23enum PostStatus: string4{5 case Draft = 'draft';67 case Published = 'published';89 case InReview = 'in-review';1011 case Scheduled = 'scheduled';12}
As we can see in the code example above, we've created a PostStatus
enum that contains four cases. Currently, the cases are string-backed, meaning we could do something like $postStatus = PostStatus::InReview->value
to get the string value (in-review
) of the InReview
case. But we might want more control over the string value that is returned. For example, we might want to return a more human-friendly label such as In Review
instead of in-review
.
To do this, we will create a new Friendly
attribute that we can apply to the enum cases. This attribute will accept a string argument that will be used as the human-friendly label.
We'll first need to create our attribute. In PHP, attributes are just classes that are annotated with the #[\Attribute]
attribute. So we'll create a new Friendly
attribute and place it in our App\Attributes
namespace:
app/Attributes/Friendly.php
1namespace App\Attributes;23use Attribute;45#[Attribute]6final readonly class Friendly7{8 public function __construct(9 public string $friendly,10 ) {11 //12 }13}
As we see above, we've just created a new Friendly
class with a constructor that accepts a single string argument. This argument will be used as the human-friendly label for the enum case.
We can then apply the Friendly
attribute to our enum cases like this:
app/Enums/PostStatus.php
1namespace App\Enums;23use App\Attributes\Friendly;45enum PostStatus: string6{7 #[Friendly('Draft')]8 case Draft = 'draft';910 #[Friendly('Published')]11 case Published = 'published';1213 #[Friendly('In Review')]14 case InReview = 'in-review';1516 #[Friendly('Scheduled to Publish')]17 case Scheduled = 'scheduled';18}
In the code example above, we've assigned the Friendly
attribute to each enum case. For example, the friendly label for the PostStatus::Scheduled
case is Scheduled to Publish
.
Now that we've assigned the attributes, we need to be able to read them in our code. We will create a new friendly
method on our PostStatus
enum that will allow us to use code such as PostStatus::InReview->friendly()
to get the friendly label.
Let's take a look at the completed enum with this code, and then we'll discuss what's being done:
app/Enums/PostStatus.php
1namespace App\Enums;23use App\Attributes\Friendly;4use ReflectionClassConstant;56enum PostStatus: string7{8 #[Friendly('Draft')]9 case Draft = 'draft';1011 #[Friendly('Published')]12 case Published = 'published';1314 #[Friendly('In Review')]15 case InReview = 'in-review';1617 #[Friendly('Scheduled to Publish')]18 case Scheduled = 'scheduled';1920 public function friendly(): string21 {22 // Create a ReflectionClassConstant instance for the enum case23 // and attempt to read the Friendly attribute.24 $attributes = (new ReflectionClassConstant(25 class: self::class,26 constant: $this->name,27 ))->getAttributes(28 name: Friendly::class,29 );3031 // If a Friendly attribute isn't found for the enum case,32 // throw an exception.33 if ($attributes === []) {34 throw new \RuntimeException(35 message: 'No friendly attribute found for ' . $this->name,36 );37 }3839 // Create a new instance of the Friendly attribute40 // and return the friendly value.41 return $attributes[0]->newInstance()->friendly;42 }43}
In the code example above, we've added a new friendly
method that returns a string. We start by creating a new ReflectionClassConstant
instance that allows us to inspect the particular enum case that we're calling this method on. We then use the getAttributes
method to attempt to read the Friendly
attribute. It's worth noting that the getAttributes
method returns an array of ReflectionAttribute
instances. If the attribute isn't found (for example, because a developer hasn't added the attribute to the enum case), then an empty array will be returned.
We then check whether the attribute was found by checking if the $attributes
array is empty. If the array is empty, we throw a \RuntimeException
exception. Doing this enforces that the Friendly
attribute must be added to the enum case. However, depending on your use case, you may want to return a default value instead. For example, you might want to apply some formatting to the enum case value and return that.
If we do find the Friendly
attribute, then $attributes[0]
will be an instance of the ReflectionAttribute
class. We can then use the newInstance
method to create a new instance of the Friendly
attribute and return its friendly
property.
This allows us to write code such as:
1echo PostStatus::InReview->friendly(); // In Review2echo PostStatus::Scheduled->friendly(); // Scheduled to Publish
Because adding a "friendly" label to an enum case could be a common use case, you may want to extract this into a trait you can in other enum classes. For example, you may want to create an App\Traits\HasFriendlyEnumLabels
trait that you can then use in your enum classes:
app/Traits/HasFriendlyEnumLabels.php
1namespace App\Traits;23use App\Attributes\Friendly;4use ReflectionClassConstant;56trait HasFriendlyEnumLabels7{8 public function friendly(): string9 {10 // Create a ReflectionClassConstant instance for the enum case11 // and attempt to read the Friendly attribute.12 $attributes = (new ReflectionClassConstant(13 class: self::class,14 constant: $this->name,15 ))->getAttributes(16 name: Friendly::class,17 );1819 // If a Friendly attribute isn't found for the enum case,20 // throw an exception.21 if ($attributes === []) {22 throw new \RuntimeException(23 message: 'No friendly attribute found for ' . $this->name,24 );25 }2627 // Create a new instance of the Friendly attribute28 // and return the friendly value.29 return $attributes[0]->newInstance()->friendly;30 }31}
This could then be used in our PostStatus
enum like this:
app/Enums/PostStatus.php
1namespace App\Enums;23use App\Attributes\Friendly;4use App\Traits\HasFriendlyEnumLabels;56enum PostStatus: string7{8 use HasFriendlyEnumLabels;910 #[Friendly('Draft')]11 case Draft = 'draft';1213 #[Friendly('Published')]14 case Published = 'published';1516 #[Friendly('In Review')]17 case InReview = 'in-review';1819 #[Friendly('Scheduled to Publish')]20 case Scheduled = 'scheduled';21}
As we can see in the code example, this makes the enum look a lot cleaner and easier to read.
Declaring the Attribute's Target
As we've already mentioned, attributes can be applied to many parts of your code, such as classes, class methods, properties, etc. However, there may be times when you want an attribute only to be used in a specific context. For example, we'd only expect our Friendly
attribute to be used with enum values. We wouldn't expect it to be used on a class or a class method, for instance.
PHP allows you to optionally specify the target for the attribute when you're declaring it. Let's update our Friendly
attribute, and then we'll delve into this a bit further. Since we only want the Friendly
attribute to be used on enum cases, we can update it to specify this:
app/Attributes/Friendly.php
1namespace App\Attributes;23use Attribute;45#[Attribute(Attribute::TARGET_CLASS_CONSTANT)]6final readonly class Friendly7{8 public function __construct(9 public string $friendly,10 ) {11 //12 }13}
In our Friendly
attribute class, we can see that we've passed a parameter to the #[Attribute]
declaration specifying that it should only be used on class constants (which will allow us to use it on enum cases).
You can specify the following targets for an attribute:
-
Attribute::TARGET_CLASS
- Defines that the attribute can only be applied to classes. -
Attribute::TARGET_FUNCTION
- Defines that the attribute can only be applied to functions. -
Attribute::TARGET_METHOD
- Defines that the attribute can only be applied to class methods. -
Attribute::TARGET_PROPERTY
- Defines that the attribute can only be applied to class properties. -
Attribute::TARGET_CLASS_CONSTANT
- Defines that the attribute can only be applied to class constants and enums. -
Attribute::TARGET_PARAMETER
- Defines that the attribute can only be applied to function or method parameters. -
Attribute::TARGET_ALL
- Defines that the attribute can be applied anywhere.
Although specifying the target is optional, I like to specify it whenever I create an attribute. This is because it allows me to be more explicit and makes it easier for other developers to understand how the attribute should be used. This is just personal preference.
It's important to remember that if you apply an attribute to the wrong type of target, PHP won't typically throw an exception at runtime unless you're interacting with it. For example, let's say we set the Friendly
attribute's target to Attribute::TARGET_CLASS
(intending for it only to be applied to classes). This code would still work because we're not interacting with the attribute in any way:
1echo PostStatus::InReview->value; // in-review
However, running this code would result in an Error
being thrown:
1echo PostStatus::InReview->friendly();
The error message would read:
1Attribute "App\Attributes\Friendly" cannot target class constant (allowed targets: class)
Declaring Repeatable Attributes
There may be times when you want to allow an attribute to be applied multiple times to the same target. For example, let's take PHPUnit's #[TestWith]
attribute. You can either apply it once and pass multiple arguments to it or apply it multiple times with a single argument. For example, you can do this:
1use PHPUnit\Framework\Attributes\Test; 2use PHPUnit\Framework\Attributes\TestWith; 3 4#[Test] 5#[TestWith('superadmin', 'admin', 'user')] 6public function access_is_denied_if_the_user_does_not_have_permission( 7 string $permission 8): { 9 // Test goes here...10}
Or you can do this:
1use PHPUnit\Framework\Attributes\Test; 2use PHPUnit\Framework\Attributes\TestWith; 3 4#[Test] 5#[TestWith('superadmin')] 6#[TestWith('admin')] 7#[TestWith('user')] 8public function access_is_denied_if_the_user_does_not_have_permission( 9 string $permission10): {11 // Test goes here...12}
Both of these code examples would result in the test being run three times (once for each of the permissions passed in).
To indicate that a particular attribute can be applied multiple times to the same target, you can use the #[Attribute(Attribute::IS_REPEATABLE)]
declaration. For example, the declaration for PHPUnit's #[TestWith]
attribute looks like this:
1namespace PHPUnit\Framework\Attributes;2 3use Attribute;4 5#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]6final class TestWith7{8 // ...9}
As we can see in the code example, the #[TestWith]
attribute is intended to be applied to class methods and can be applied multiple times to the same target (in this case, a class method). This is specified by the Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE
bitmask that is passed to the #[Attribute]
attribute.
If the Attribute::IS_REPEATABLE
flag isn't used and the attribute is repeated on the same target, your IDE will typically highlight this as an error.
Testing Code that Uses Attributes
Like any other piece of your PHP application, writing tests for the code that interacts with your attributes is important. You can use the tests to give you confidence that you've applied the attributes correctly and that they're also being read correctly.
Sticking with our Friendly
attribute example from earlier, let's imagine we want to write some tests for our PostStatus
enum and the HasFriendlyEnumLabels
trait.
We'll start by writing a test for the HasFriendlyEnumLabels
trait. We want to test two different scenarios:
- A friendly label is returned if the
Friendly
attribute is applied to the enum case. - An exception is thrown if the
Friendly
attribute is not applied to the enum case.
Our test class may look something like this:
1namespace Tests\Feature\Traits\HasFriendlyEnumLabels; 2 3use App\Attributes\Friendly; 4use App\Traits\HasFriendlyEnumLabels; 5use PHPUnit\Framework\Attributes\Test; 6use Tests\TestCase; 7 8final class FriendlyTest extends TestCase 9{10 #[Test]11 public function friendly_label_can_be_returned(): void12 {13 $this->assertSame(14 'Has Friendly Value',15 MyTestEnum::HasFriendlyValue->friendly(),16 );17 }18 19 #[Test]20 public function error_is_thrown_if_the_friendly_attribute_is_not_applied(): void21 {22 $this->expectException(\RuntimeException::class);23 24 $this->expectExceptionMessage(25 'No friendly attribute found for HasNoFriendlyValue',26 );27 28 MyTestEnum::HasNoFriendlyValue->friendly();29 }30}31 32enum MyTestEnum: string33{34 use HasFriendlyEnumLabels;35 36 #[Friendly('Has Friendly Value')]37 case HasFriendlyValue = 'has-friendly-value';38 39 case HasNoFriendlyValue = 'has-no-friendly-value';40}
As we can see in the code example above, we've written two tests (one to test each of the scenarios we specified above). This means we can have some confidence that the HasFriendlyEnumLabels
trait is working as expected.
You may have noticed that in order to test this trait, we've created a new MyTestEnum
enum and placed it at the bottom of the PHP file after the test class. This is because we want to purposely create a new enum that has a case that doesn't have the Friendly
attribute applied to it. This allows us to test that the exception is thrown if the attribute isn't applied. Although I wouldn't typically recommend adding multiple classes to the same file, I think it's acceptable in this case because it's only being used for testing purposes. However, if you're uncomfortable with this approach, you may want to consider creating a separate file for your test enum.
We may also want to write some tests for our PostStatus
enum to ensure that all the cases have a Friendly
attribute applied to them. Let's take a look at the test class, and then we'll discuss what's being done:
1namespace Tests\Feature\Enums\PostStatus; 2 3use App\Enums\PostStatus; 4use PHPUnit\Framework\Attributes\DataProvider; 5use PHPUnit\Framework\Attributes\Test; 6use Tests\TestCase; 7 8final class FriendlyTest extends TestCase 9{10 #[Test]11 #[DataProvider('casesProvider')]12 public function each_case_has_a_friendly_label(13 PostStatus $postStatus14 ): void {15 $this->assertNotNull($postStatus->friendly();16 }17 18 public static function casesProvider(): array19 {20 return array_map(21 callback: static fn (PostStatus $case): array => [$case],22 array: PostStatus::cases(),23 );24 }25}
In this test, we're using PHPUnit's #[DataProvider]
attribute to specify a data provider for the test method. In the data provider (the casesProvider
method), we're building an array of arrays that contain a single item each (the enum case) so that PHPUnit can pass these enums to the test method itself. The returned array will look something like this:
1[2 [PostStatus::Draft],3 [PostStatus::Published],4 [PostStatus::InReview],5 [PostStatus::Scheduled],6]
This means the test method will be run four times (once for each enum case). We're then calling the friendly
method on the enum case and just asserting that the result is not null
. Although this test isn't explicitly testing the strings that are being returned, it's extremely useful for giving us confidence that the Friendly
attribute has been applied to each of the enum cases. This means if we add a new enum case in the future and forget to add the Friendly
attribute to it, this test will fail so we can fix it before the code is deployed to production.
Depending on how you use attributes in your code, you may want to add more explicit tests. However, for our particular use case, I think these tests provide a strong enough level of confidence that the code is working as expected.
Conclusion
In this article, we've learned what attributes are, what their purpose in your code is, and how to use them. We've also learned how to create your own attributes and how to test code that uses attributes.
You should now feel confident enough to start using attributes in your own PHP applications.