PHP Enums in Depth: Backed Enums, Methods, and Interfaces

PHP 8.1 introduced enums as a first-class language feature. After a few years in the wild they are well-supported across frameworks, Drupal, and static analysis tools — but many PHP developers still use constants or pseudo-enum classes out of habit. This article covers everything you need to use enums effectively: pure enums, backed enums, methods on enums, interface implementation, and the patterns that emerge when you integrate enums into domain models, Drupal fields, and API responses.


Pure Enums

A pure enum has no backing type. Its cases are values in themselves, not wrappers around integers or strings:

<?php

enum Status
{
    case Draft;
    case Published;
    case Archived;
}

$status = Status::Draft;
var_dump($status === Status::Draft); // true
var_dump($status instanceof Status); // true

Pure enum cases cannot be cast to a scalar. You cannot pass a Status case to a function expecting a string. This is the point: the type system enforces that only valid statuses can be used where a Status is expected.

function publish(Status $status): void
{
    if ($status !== Status::Draft) {
        throw new \LogicException('Only draft content can be published.');
    }
    // ...
}

publish(Status::Draft);      // OK
publish(Status::Published);  // Logically valid — runtime rejects it
publish('Draft');             // Fatal TypeError — cannot pass string

Backed Enums

A backed enum associates each case with a scalar value — either int or string. The backing type is declared after the enum name with a colon:

<?php

enum Status: string
{
    case Draft     = 'draft';
    case Published = 'published';
    case Archived  = 'archived';
}

// Access the backing value
echo Status::Published->value; // published

// Create from a backing value
$status = Status::from('draft');       // Status::Draft
$status = Status::tryFrom('unknown');  // null (no exception)
$status = Status::from('unknown');     // ValueError — throws

Backed enums are what you reach for when the enum value needs to be persisted — in a database column, a URL parameter, a JSON payload, or a Drupal field.

Integer-backed Enums

<?php

enum Priority: int
{
    case Low    = 1;
    case Medium = 5;
    case High   = 10;
}

echo Priority::High->value; // 10

// Useful for comparisons
function isUrgent(Priority $p): bool
{
    return $p->value >= Priority::High->value;
}

Methods on Enums

Enums can have methods, which is where they become significantly more powerful than constants:

<?php

enum Status: string
{
    case Draft     = 'draft';
    case Published = 'published';
    case Archived  = 'archived';

    public function label(): string
    {
        return match($this) {
            Status::Draft     => 'Draft',
            Status::Published => 'Live',
            Status::Archived  => 'Archived',
        };
    }

    public function canTransitionTo(Status $next): bool
    {
        return match($this) {
            Status::Draft     => $next === Status::Published,
            Status::Published => $next === Status::Archived,
            Status::Archived  => false,
        };
    }

    public function isVisible(): bool
    {
        return $this === Status::Published;
    }
}

$status = Status::Draft;
echo $status->label(); // Draft

$status->canTransitionTo(Status::Published); // true
$status->canTransitionTo(Status::Archived);  // false

Putting canTransitionTo() on the enum itself is a clean example of the principle of keeping business rules close to the data they govern. The alternative — a separate StatusTransitionService or a giant switch statement in a controller — scatters the logic.

Static Methods and Named Constructors

<?php

enum Role: string
{
    case Admin    = 'admin';
    case Editor   = 'editor';
    case Viewer   = 'viewer';

    // Named constructor pattern
    public static function fromDrupalRoleName(string $name): self
    {
        return match($name) {
            'administrator'  => self::Admin,
            'content_editor' => self::Editor,
            default          => self::Viewer,
        };
    }

    public static function editorialRoles(): array
    {
        return [self::Admin, self::Editor];
    }
}

$role = Role::fromDrupalRoleName('content_editor'); // Role::Editor

Enum Cases() and Iteration

All enums expose a static cases() method returning an array of all cases. Backed enums also implement UnitEnum, and both types can be iterated:

foreach (Status::cases() as $case) {
    echo $case->name . ': ' . $case->value . "\n";
}
// Draft: draft
// Published: published
// Archived: archived

This makes it trivial to generate select options for a form or a database ENUM type without maintaining a separate array of valid values:

// Drupal form element
$form['status'] = [
    '#type'    => 'select',
    '#title'   => $this->t('Status'),
    '#options' => array_column(
        array_map(
            fn(Status $s) => ['value' => $s->value, 'label' => $s->label()],
            Status::cases()
        ),
        'label',
        'value'
    ),
    '#default_value' => Status::Draft->value,
];

Implementing Interfaces

Enums can implement interfaces, which opens up polymorphic patterns:

<?php

interface HasLabel
{
    public function label(): string;
}

interface HasColor
{
    public function color(): string;
}

enum Status: string implements HasLabel, HasColor
{
    case Draft     = 'draft';
    case Published = 'published';
    case Archived  = 'archived';

    public function label(): string
    {
        return match($this) {
            self::Draft     => 'Draft',
            self::Published => 'Live',
            self::Archived  => 'Archived',
        };
    }

    public function color(): string
    {
        return match($this) {
            self::Draft     => '#888888',
            self::Published => '#00aa00',
            self::Archived  => '#cc4400',
        };
    }
}

function renderBadge(HasLabel&HasColor $item): string
{
    return sprintf(
        '<span style="color:%s">%s</span>',
        $item->color(),
        htmlspecialchars($item->label())
    );
}

echo renderBadge(Status::Published);

The intersection type HasLabel&HasColor on renderBadge means any enum (or class) that implements both interfaces can be passed. You get code reuse without inheritance.


Enum Constants

Enums can define constants. Unlike cases, constants can hold any value type including arrays and other enums:

<?php

enum Permission: string
{
    case Read   = 'read';
    case Write  = 'write';
    case Delete = 'delete';

    const ADMIN_PERMISSIONS = [self::Read, self::Write, self::Delete];
    const VIEWER_PERMISSIONS = [self::Read];

    public static function forRole(Role $role): array
    {
        return match($role) {
            Role::Admin  => self::ADMIN_PERMISSIONS,
            Role::Editor => [self::Read, self::Write],
            Role::Viewer => self::VIEWER_PERMISSIONS,
        };
    }
}

Enums in Drupal: Persisting to Fields

A common pattern in Drupal custom modules is storing enum values in a string field and converting them on read. Here is a clean way to handle this with a typed accessor on a content entity:

<?php

namespace Drupal\my_module\Entity;

use Drupal\my_module\Enum\Status;
use Drupal\node\Entity\Node;

class Article extends Node
{
    public function getStatus(): Status
    {
        $value = $this->get('field_status')->value ?? Status::Draft->value;
        return Status::from($value);
    }

    public function setStatus(Status $status): static
    {
        $this->set('field_status', $status->value);
        return $this;
    }

    public function publish(): void
    {
        $current = $this->getStatus();
        if (!$current->canTransitionTo(Status::Published)) {
            throw new \LogicException(
                "Cannot publish from status: {$current->label()}"
            );
        }
        $this->setStatus(Status::Published);
    }
}

The field stores a plain string ('draft', 'published', 'archived'). The entity accessor converts it to a typed Status enum on every read. Business logic lives on the enum itself (canTransitionTo()). The entity method orchestrates the transition.


JSON Serialisation

Backed enums implement JsonSerializable — they serialise to their backing value automatically:

echo json_encode(Status::Published); // "published"
echo json_encode(Priority::High);    // 10

Decoding JSON back to an enum requires a manual step:

$data = json_decode('{"status":"published"}', true);
$status = Status::from($data['status']); // Status::Published

In API contexts, Status::tryFrom() is safer than Status::from() — it returns null instead of throwing when the value is not recognised, letting you return a 400 response rather than a 500.


What Enums Cannot Do

  • No properties. Enum cases cannot have instance properties other than the backing value. Use methods that return computed values instead.
  • No constructor. Enums do not allow user-defined constructors. The backed value is set by the case declaration.
  • No extending. Enums cannot extend other enums or classes. They can implement interfaces.
  • Not instantiable. You cannot call new Status(). Access cases via Status::Draft or Status::from().
  • No nullable cases. ?Status in a type hint means the value is either a Status instance or null — there is no "null case".

PHPStan and Enums

PHPStan understands enums natively from level 0. At level 8 it will catch:

  • Passing a string where a backed enum is expected
  • Missing cases in match expressions over an enum type
  • Calling ->value on a pure (non-backed) enum case
  • Unreachable code after an exhaustive match

The last two are the most valuable. If you add a new case to an enum and forget to update a match expression, PHPStan catches it before the code runs — provided you have PHPStan at level 8 and your match has no default arm.

// PHPStan will flag this as non-exhaustive if you add Status::Scheduled
echo match($status) {
    Status::Draft     => 'draft-class',
    Status::Published => 'published-class',
    Status::Archived  => 'archived-class',
    // No default — any new case will be a static analysis error
};

Summary Checklist

  • Use backed enums (enum Foo: string or enum Foo: int) when the value needs to be persisted or serialised.
  • Use pure enums when the value is only meaningful within the application — no need to cross a persistence boundary.
  • Put business logic — label methods, transition rules, permission sets — directly on the enum rather than in services or controllers.
  • Implement interfaces on enums for polymorphic behaviour without inheritance.
  • Use Status::tryFrom() in input-handling code; use Status::from() when the value is trusted (e.g. read from your own database).
  • Use non-exhaustive match (no default arm) over enum types to make PHPStan catch missing cases when you add new values.
  • In Drupal, store the ->value string in the field; convert back to the enum in a typed getter method on the entity.
  • Iterate with EnumClass::cases() to generate form options or migration maps instead of maintaining a separate array.

Add new comment

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.
Please share this article on your favorite website or platform.