Search code examples
phpsymfonyenumsstatic-analysisphpstan

Using Enum as string in PHP with PhpStan


I'm experimenting with PHP enums in a Symfony 6 application and I thought I found a very nice use case for those. Everything works, but phpstan keeps complaining about the type I return.

 ------ -----------------------------------------------------------------------------------------------------------------------------------------------
  Line   src/Entity/User.php
 ------ -----------------------------------------------------------------------------------------------------------------------------------------------
  93     Return type (array<App\Security\Roles>) of method App\Entity\User::getRoles() should be compatible with return type (array<string>) of method
         Symfony\Component\Security\Core\User\UserInterface::getRoles()
 ------ -----------------------------------------------------------------------------------------------------------------------------------------------

My Enum looks like this:

namespace App\Security;

enum Roles: string
{
    case Admin = 'ROLE_ADMIN';
    case User = 'ROLE_USER';
}

In my User entity which implements Symfony\Component\Security\Core\User\UserInterface, I have this:

/**
 * @see UserInterface
 * @return array<Roles>
 */
public function getRoles(): array
{
    $roles = $this->roles;
    // guarantee every user at least has ROLE_USER
    $roles[] = Roles::User->value;

    return array_unique($roles);
}

/**
 * @param array<Roles> $roles
 */
public function setRoles(array $roles): self
{
    $this->roles = $roles;

    return $this;
}

I understand that phpstan expects the array to contain a string as defined in the interface,

/**
 * @return string[]
 */
public function getRoles(): array;

But is there a way to work around this? I would really prefer to be able to leverage an Enum. Isn't the point of adding a type to the enum being able to use it as that type? Also worth mentioning that I use strict types everywhere and everything works except for phpstan being not too happy with my type.


Solution

  • Your return annotation is wrong, as PhpStan is correctly reporting.

    You say you report an array of roles:

    /**
     *  @return array<Roles>
     */
    

    If that were the case, roles would contain an enum directly, not the value of a backed enum:

    $this->roles[] = Roles::Admin
    

    By keeping the wrong annotation, you are not only upsetting PhpStan, but you give the wrong information to consumers of getRoles(). The annotation you have means that I should be able to do:

    /** @var array<Roles> */
    $roles = $user->getRoles();
    
    foreach ($roles as $role) {
       if ($role === Roles::Admin) {
           // something
       }
    }
    

    But you are actually returning string[], not Roles[], so my code wouldn't work and my application would crash. Sadness througout the land.

    Without seeing additional code, it does not look you are really "using enums" in a way different from class constants. roles is not a collection of Roles, your addRole() metod would take a string, not a Roles, etc.

    Which is perfectly fine, of course. Since you need to comply with the UserInterface, maybe a class with some constants would be a be a better fit here.

    /**
     *  @return array<Roles::ROLE_*>
     */
    

    A naive example of this annotation working would be:

    <?php declare(strict_types = 1);
    
    abstract class Roles {
        const ROLE_FOO = 'foo';
        const ROLE_BAR = 'bar';
        const ROLE_BAZ = 'baz';
    }
    
    /**
     * @param array<Roles::*> $s
     */
    function sayHello(array $s): void
        {
            foreach ($s as $si) {
                echo 'Hello, ' . $si, "\n";
            }
        }
    
    $array = [
        Roles::ROLE_FOO,
        Roles::ROLE_BAR
    ];
    
    sayHello($array);
    
    $badArray = [
        Roles::ROLE_FOO,
        'some'
    ];
    
    // this is an error!
    sayHello($badArray);
    

    (playground link)

    If you want to use Enums for your roles, then actually use them. But you won't be able to use them directly in getRoles() because it would break the interface.

    You could have a companion set of methods (getRealRoles(), addRealRole(Roles $role), etc), wich would deal with the roles property, and you'd leave getRoles() simply to convert the array<Roles> into an array<string>.

    No, you wouldn't be able to express "getRoles returns an array comprised of elments that are the possible backed values of the Roles enum", but it's fine, because in your (as opposed to framework code, which does not care about your implementation) code you would use the methods that work with the enums.