Latest Posts

Using PHP Attributes to enhance the capabilities of Enums

Tutorials

PHP Attributes are a powerful feature of PHP >= 8. In this example, I provide a brief introduction to using reflection to enhance your Enums with PHP attributes


Attributes allow us to add metadata to classes, methods and enum cases to name a few. They give us a way to enhance the existing capabilities by using PHP Reflection to fetch and process the attributes data.

Use case

My use case is pretty simple, I have a PHP Enum with a number of cases. For each case, I want a physical string error reason, an error code and a more detailed description that I can output to the user.

Sure, for this example I could just as well add a switch to a method, check what particular enum case we're in and return a matched string. But, firstly wheres the fun in that - lets use the new tech! And secondly, this option provides me extensibility in the future. You can build on attributes, provide more data, and utilise them in different ways. Finally, this is just an example use case - the possibilities are only limited only to your imagination!

Building the Enum

Firstly, we need our enum, it's a simple string backed enum with a number of reasons as to why the user has been denied access. We'll call this Enum DenialReason

<?php
	
namespace App\Enums;
	
enum DenialReason: string
{
    case InvalidState = 'invalid_state';
    case EmailNotFound = 'email_not_found';
    case GoogleIdMismatch = 'google_id_mismatch';
}

Building the attribute

So we've got a very simple enum implementation done, now it's time to enhance that enum by creating an attribute. I want our attribute to capture a description, and a status code to return to the user.

The actual building of attributes is very simple, the class simply needs to be annotated with #[\Attribute]. Given that our attribute will house the specifics of our error, I'll class this one ErrorDetails and we'll capture a description, and an HTTP status code.

<?php

namespace App\Attributes;

#[\Attribute]
class ErrorDetails
{
    public function __construct(public string $description, public int $status = 403)
    {
        //
    }
}

Lets enhance our enum

As you can see above, an attribute really is about as simple as it gets - of course you can add to this like you would a normal class - which makes it really quite powerful. But for now, we'll stick to the basics.

So I want to be able to apply a description and status code to each one of our enum states. Now we've got our attribute, that begins to look quite easy...

<?php

namespace App\Enums;

use App\Attributes\ErrorDetails;

enum DenialReason: string
{
    #[ErrorDetails('The state parameter did not match the expected value.', 400)]
    case InvalidState = 'invalid_state';

    #[ErrorDetails('The email address provided by Google did not match any existing user.', 401)]
    case EmailNotFound = 'email_not_found';

    #[ErrorDetails('The email address provided by Google is already associated with a different Google ID.', 403)]
    case GoogleIdMismatch = 'google_id_mismatch';
}

And with that, each case now has a reason and a status code - great! So far, so simple, now it's onto the 'hard' part - reflection. While reflection may seem pretty scary, it's simply a way of looking at the actual makeup of classes and is used extensively within Laravel.

Lets create a method inside our enum that retrieves our ErrorDetails attribute. To play it safe, I'll add in some null safety and exception handling. It'll be a private method so that we can call it within the class to extract the specifics. We'll also use Laravel's once helper to prevent reflection being run multiple times.

private function getErrorDetailsAttribute(): ?ErrorDetails
{
    return once(function () {
        try {
            $reflection = new \ReflectionEnum($this);
            $attributes = $reflection->getCase($this->name)->getAttributes(ErrorDetails::class);
        } catch (\ReflectionException) {
            return null;
        }

        return ($attributes[0] ?? null)?->newInstance();
    });
}

And with that, we've got a null-safe and cached way of retrieving our error details. So lets finalise this with two getter methods to easily pull the description and status code out of the enum. Again, we'll make sure these are null-safe.

public function getDescription(): ?string
{
    return $this->getErrorDetailsAttribute()?->description;
}

public function getStatusCode(): ?int
{
    return $this->getErrorDetailsAttribute()?->status;
}

Putting it all together

Now we've gone through all of the necessary methods, lets bundle it up into our enum class:

<?php
	
namespace App\Enums;

use App\Attributes\ErrorDetails;

enum DenialReason: string
{
    #[ErrorDetails('The state parameter did not match the expected value.', 400)]
    case InvalidState = 'invalid_state';

    #[ErrorDetails('The email address provided by Google did not match any existing user.', 401)]
    case EmailNotFound = 'email_not_found';

    #[ErrorDetails('The email address provided by Google is already associated with a different Google ID.', 403)]
    case GoogleIdMismatch = 'google_id_mismatch';

    public function getDescription(): ?string
    {
        return $this->getErrorDetailsAttribute()?->description;
    }

    public function getStatusCode(): ?int
    {
        return $this->getErrorDetailsAttribute()?->status;
    }

    private function getErrorDetailsAttribute(): ?ErrorDetails
    {
        return once(function () {
            try {
                $reflection = new \ReflectionEnum($this);
                $attributes = $reflection->getCase($this->name)->getAttributes(ErrorDetails::class);
            } catch (\ReflectionException) {
                return null;
            }

            return ($attributes[0] ?? null)?->newInstance();
        });
    }
}

And that's it! You can put this into practice by calling your enum, for example:

App\Enums\DenialReason::EmailNotFound->getDescription()

Hopefully you've seen from this simple example that there's much more to Attributes than you might think. This is just the tip of the iceberg!

One of my favourite uses of this within Laravel is the ObservedBy attribute. It allows you to directly register observers against your models and provides a clean and fluent way to see what observers are bound against your model. No more digging into the EventServiceProvider (woohoo!).

use App\Observers\UserObserver;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
 
#[ObservedBy([UserObserver::class])]
class User extends Authenticatable
{
    //
}

If you enjoyed this post, please consider subscribing for more!