Extending PHP Enums with Interfaces and Custom Methods
AI generated
<?php
8.4
PHP 8.4 · Enums · Interfaces · OOP
Extending PHP Enums with Interfaces and Custom Methods
from a rigid value list to a polymorphic building block

Anyone who treats enums as nothing more than a type-safe list of constants is missing PHP's real strength: enums with interfaces implement genuine object-oriented behavior per case, replace match blocks scattered across the caller code with a single method inside the enum, and force new cases to surface through the compiler instead of through grep searches across the project.

12 min read Enums · Interfaces · Methods · match · Strategy Pattern PHP 8.1 · 8.2 · 8.3 · 8.4

1. Why plain enums often aren't enough

Since PHP 8.1, enums have solved an old problem: fixed value sets used to be modeled with class constants or raw strings, without the compiler ever catching an invalid value. An OrderStatus enum guarantees that only the defined cases can exist. But as soon as behavior belongs to that status too, such as a display color, a translated label, or a permission check, the plain value set stops being enough. This is exactly where enums with interfaces come in.

Without this pattern, the case distinction migrates into external functions: a getStatusColor() function with a match($status) block here, a getStatusLabel() function with an almost identical block there. Every new variant of the enum forces you to find and update every one of these scattered spots across the project. Miss one, and the application breaks only at runtime, the first time that exact case occurs, often in production rather than in a test.

Enums with interfaces fix this at the root by anchoring the behavior directly on the case. Instead of scattering logic outside the enum, the enum implements an interface and defines the matching method itself. The compiler enforces that every method of the interface is present, and every new case automatically inherits the same structure. That makes this pattern the natural next step as soon as an enum needs to carry more than a single value.

2. Enums implement interfaces like any other class

Technically, a PHP enum is a special kind of class with a fixed, compiler-controlled number of instances, the cases. That is exactly why interface syntax on enums works identically to ordinary classes: enum OrderStatus implements HasColor obligates the enum to implement every method declared on the interface. This symmetry with classes is the core of what makes enums with interfaces so useful, because existing knowledge about interfaces transfers one to one.

The key difference from an ordinary class is that every method inside the enum body can access $this directly, and $this always represents exactly one of the defined cases. An interface method like color(): string can therefore return a different string per case, without the caller ever needing to know how many cases exist or which one is currently at hand. That is genuine polymorphism, not just a value lookup.


<?php

declare(strict_types=1);

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

enum OrderStatus: string implements HasColor
{
    case Pending = 'pending';
    case Shipped = 'shipped';
    case Delivered = 'delivered';
    case Cancelled = 'cancelled';

    // Interface method implemented once, resolved per case via match($this)
    public function color(): string
    {
        return match ($this) {
            self::Pending => '#f59e0b',
            self::Shipped => '#3b82f6',
            self::Delivered => '#22c55e',
            self::Cancelled => '#ef4444',
        };
    }
}

function renderBadge(HasColor $status): string
{
    // Caller only depends on the interface, not on OrderStatus itself
    return sprintf('<span style="color:%s">', $status->color());
}

echo renderBadge(OrderStatus::Shipped);

3. Defining your own methods directly in the enum

Beyond the methods an interface forces into existence, enums can define arbitrary methods of their own that no interface requires. That is the second building block of enums with interfaces: interface methods form the externally visible contract, custom methods encapsulate internal helper logic shared by multiple interface methods. A private helper method inside an enum is syntactically identical to a private method in a class.

Class constants are also allowed inside enums and are frequently underused. A const DEFAULT_LABEL = 'Unknown' inside the enum is available to every method, without being visible outside the enum, as long as it is declared as a private const. This combination of custom methods and private constants turns an enum into a fully self-contained behavior unit, rather than just a container for raw values.

A common pattern: a public interface method label(): string internally calls a private method rawLabel(): string that additionally applies some formatting, such as capitalization or appending a suffix. That keeps the public interface stable while the internal formatting can change without touching the caller code.


<?php

declare(strict_types=1);

enum Priority: int
{
    case Low = 1;
    case Medium = 2;
    case High = 3;

    private const string SUFFIX = ' priority';

    // Public method delegates formatting to a private helper
    public function label(): string
    {
        return ucfirst($this->rawLabel()) . self::SUFFIX;
    }

    // Private helper: not part of any interface, pure internal detail
    private function rawLabel(): string
    {
        return strtolower($this->name);
    }
}

echo Priority::High->label(); // "High priority"

4. Implementing multiple interfaces at once

An enum is not limited to a single interface. Just like a class, an enum can implement several interfaces simultaneously, separated by commas: enum OrderStatus implements HasColor, HasLabel, JsonSerializable. This composability is one of the strongest reasons enums with interfaces work so well in mature codebases, because different callers can each require only the interface they actually need.

This separation, following the interface segregation principle, means a function that only needs the display color declares its parameter as HasColor, not as OrderStatus. A test can then easily pass a simple test enum with only that one interface, without dragging in the full production logic. Without enums with interfaces, a test would have to either instantiate the whole production enum or rely on mocking libraries, which does not work with a final enum anyway.


<?php

declare(strict_types=1);

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

enum OrderStatus: string implements HasColor, HasLabel, JsonSerializable
{
    case Pending = 'pending';
    case Shipped = 'shipped';
    case Delivered = 'delivered';
    case Cancelled = 'cancelled';

    private const DEFAULT_LABEL = 'Unknown';

    public function color(): string
    {
        return match ($this) {
            self::Pending => '#f59e0b',
            self::Shipped => '#3b82f6',
            self::Delivered => '#22c55e',
            self::Cancelled => '#ef4444',
        };
    }

    public function label(): string
    {
        return match ($this) {
            self::Pending => 'Pending',
            self::Shipped => 'Shipped',
            self::Delivered => 'Delivered',
            self::Cancelled => 'Cancelled',
        } ?: self::DEFAULT_LABEL;
    }

    // JsonSerializable is required because backed enums serialize to
    // their scalar value only, never as a rich object, by default
    public function jsonSerialize(): array
    {
        return ['value' => $this->value, 'label' => $this->label()];
    }
}

5. Static methods and factory patterns in enums

Besides instance methods, enums also allow static methods that are not bound to a specific case. A typical use case in the context of enums with interfaces is a static factory method that derives the matching case from an external, possibly inconsistent value, such as a legacy database code. Unlike from() and tryFrom() on backed enums, a custom static method can apply extra normalization before determining the case.

Assembling lookup structures from self::cases() is another useful application of static methods. A method like labels(): array that iterates over every case and builds an associative array of value and label is maintained in exactly one place inside the enum, instead of being rebuilt in every form or every API response. That substantially reduces duplication, especially when several frontends need the same option list.


<?php

declare(strict_types=1);

enum OrderStatus: string implements HasColor, HasLabel
{
    case Pending = 'pending';
    case Shipped = 'shipped';
    case Delivered = 'delivered';
    case Cancelled = 'cancelled';

    // Static factory: normalizes legacy codes before resolving the case
    public static function fromLegacyCode(string $legacyCode): self
    {
        $normalized = strtolower(trim($legacyCode));

        return match ($normalized) {
            'p', 'open' => self::Pending,
            's', 'sent' => self::Shipped,
            'd', 'done' => self::Delivered,
            'c', 'void' => self::Cancelled,
            default => throw new ValueError("Unknown legacy code: {$legacyCode}"),
        };
    }

    // Static helper built from cases(), maintained in a single place
    public static function labels(): array
    {
        $result = [];
        foreach (self::cases() as $case) {
            $result[$case->value] = $case->label();
        }

        return $result;
    }

    public function color(): string
    {
        return match ($this) {
            self::Pending => '#f59e0b',
            self::Shipped => '#3b82f6',
            self::Delivered => '#22c55e',
            self::Cancelled => '#ef4444',
        };
    }

    public function label(): string
    {
        return match ($this) {
            self::Pending => 'Pending',
            self::Shipped => 'Shipped',
            self::Delivered => 'Delivered',
            self::Cancelled => 'Cancelled',
        };
    }
}

$status = OrderStatus::fromLegacyCode('OPEN');
print_r(OrderStatus::labels());

6. match expressions in enum methods for case-specific behavior

match($this) is the central building block for distinguishing between cases inside an enum method. Unlike switch, match allows no fallthrough and enforces strict comparison, which practically rules out comparison mistakes between cases. With enums with interfaces, match($this) is the natural choice because $this, inside an enum context, is always exactly one of the defined cases, never an arbitrary external value.

An important effect: a match expression without a default branch throws an UnhandledMatchError as soon as a case occurs for which no arm exists. With enums with interfaces, this is explicitly desirable, because that exact error surfaces the moment a new case was added but a method inside the enum was forgotten to handle it. Instead of a silent, wrong default value, there is a clear, immediately visible runtime error right at the spot where the gap actually exists.

Anyone who deliberately wants a fallback can still add a default branch, but should be aware that this branch silently swallows new cases instead of drawing attention to them. In most enum methods, deliberately skipping default is therefore the more robust choice, because the exhaustiveness check at development time prevents more bugs than it causes at runtime.

7. Enums with interfaces as a lightweight strategy pattern replacement

The classic strategy pattern solves interchangeable behavior through an interface hierarchy with a separate class per strategy. For stateless, fixed behavior variants, that is often more structure than actually needed. Enums with interfaces offer a much lighter alternative here: instead of four separate classes each with its own file, a single enum with four cases and matching methods is enough, without losing expressiveness.

The advantage shows especially at instantiation time: strategies as classes often need a factory or a dependency injection container to resolve the right instance. Enum cases, by contrast, already exist as singletons at compile time, they never need to be constructed, and can be passed around, compared, and used in arrays directly as values. For strategies without their own internal state, the enum is therefore almost always the simpler solution.

As soon as a strategy needs its own mutable state or constructor-injected collaborators, though, the enum hits its limit, because enums cannot hold instance properties with their own state. In that case, the classic strategy class remains the right choice, while enums with interfaces should stay reserved for the stateless, finite cases.

8. Type safety via interfaces as parameter and return types

Once an enum implements an interface, that interface can be used as a type anywhere the concrete enum used to be the only option. A function signature like function renderBadge(HasColor $status): string accepts any enum and any class that implements HasColor, not just OrderStatus. This decoupling is one of the practical benefits of enums with interfaces over a hard-typed parameter.

For tests, this means a separate, minimal test enum with the same interface can be passed instead of the full production enum, to check edge cases without touching the actual business logic. This kind of type safety is enforced by the compiler, not merely by convention or documentation, so violations already surface during static analysis with PHPStan, long before the code is ever executed.

Task Without enums with interfaces With enums with interfaces Benefit
Determine status color match($status) at every call site $status->color() Central logic, no duplication
Add a new status Search and update every match block in the code Add a new case plus method branch in the enum Enum stays the single point of change
Testability of status logic Test several free functions individually Test enum methods in isolation directly Less test effort, cleanly encapsulated
Polymorphic behavior if/else chains over ->value Interface typehint plus method call Real polymorphism without instanceof
Interchangeable implementation Enum value queried in many places Strategy encapsulated via interface method Open-closed principle upheld

9. Limits and pitfalls: no properties, no inheritance

As powerful as enums with interfaces are, they remain limited in important ways. Enums cannot declare instance properties with mutable state, only constants. Anyone trying to write a normal property into an enum gets a parse error. Anyone who needs state per case must either compute it as a method return value or fall back to a classic class.

Likewise, an enum cannot extend another enum or a class. There is no inheritance hierarchy between enums, only interfaces and, since PHP 8.1, traits with pure methods. A trait that tries to define a property does not work inside an enum either, because the restriction to stateless methods applies regardless of whether the code sits directly in the enum or in an included trait.

Another pitfall concerns serialization: a json_encode() call on a backed enum returns only the scalar value by default, never an object with the results of its own methods. Anyone who needs the label or the color in the JSON output must additionally make the enum implement JsonSerializable and write the jsonSerialize() method themselves, as shown in the example in section 4.


<?php

declare(strict_types=1);

enum Priority: int
{
    case Low = 1;
    case Medium = 2;
    case High = 3;

    // Allowed: methods via a trait, since traits may only add behavior
    use ComparableTrait;

    // NOT allowed: a property would be a parse error inside an enum
    // public array $tags = [];

    // NOT allowed: enums cannot extend another enum or a class
    // enum Priority extends BasePriority { ... }
}

trait ComparableTrait
{
    public function isHigherThan(self $other): bool
    {
        return $this->value > $other->value;
    }
}

var_dump(Priority::High->isHigherThan(Priority::Low)); // true

10. Summary

Enums with interfaces turn a plain value set into a genuine polymorphic building block. Instead of scattering case distinctions via match($status->value) across the codebase, the enum implements an interface and defines the matching method directly on the case. New cases are forced by the compiler to implement every interface method, which turns forgotten case distinctions into an immediately visible error instead of a silent bug.

Static methods complement instance methods for factory logic and lookup tables, multiple interfaces can be combined, and match($this) remains the central tool for case-specific behavior. The limits lie in stateful requirements: as soon as a case needs its own mutable data or injected dependencies, a classic strategy class is the right choice instead of an enum.

Enums with Interfaces and Custom Methods, the Essentials

Implementing interfaces

enum X implements Y works exactly like on classes. Multiple interfaces can be combined, separated by commas.

Custom methods

match($this) is the central building block for case-specific behavior. Private constants and helper methods are allowed.

Strategy pattern replacement

Often lighter than a class hierarchy for stateless variants. Cases are singletons with no instantiation overhead.

Limits

No instance properties, no inheritance between enums. JsonSerializable is needed for more than the raw value.

11. FAQ: Enums with Interfaces and Custom Methods

1Can PHP enums have properties?
No, only constants. State per case must be computed as a method return value or requires a classic class.
2Can an enum inherit from another enum?
No. Only implementing interfaces and including traits with methods is possible, no inheritance between enums.
3Method in the enum vs. external helper function?
Cohesion and IDE autocompletion. Interface typehints also make the method usable polymorphically.
4Implement multiple interfaces at once?
Yes, like on classes: enum X implements A, B, C, as long as every required method is implemented.
5Call an interface method on a case?
Like a normal method call: $status->color(). The case is a full-fledged object at runtime.
6New case, match() doesn't cover it?
UnhandledMatchError at runtime, as soon as the case runs through. Missing cases become immediately visible instead of being silently ignored.
7Use an enum as an interface typehint?
Yes. If the enum implements the interface, any function with that interface parameter accepts the enum too.
8Can enums use traits?
Yes, traits with methods since PHP 8.1. Traits with properties don't work, since enums allow no instance state.
9More performant than a classic class hierarchy?
Cases are singletons with no instantiation overhead, an advantage over object creation per call.
10Serialize an enum with methods to JSON?
json_encode() returns only value by default. Implement JsonSerializable additionally for label or color.

Mironsoft

PHP architecture, code quality, and Magento development

Want to use enums with interfaces cleanly in your own project?

We review existing PHP code for scattered match logic and replace it with clean enums with interfaces and custom methods, with full type safety and PHPStan coverage at level 5 and above.

Code review

Analysis of scattered match blocks and proposals for enum refactorings

Refactoring

Migrating existing status values to enums with interfaces and methods

PHPStan coverage

Static analysis at level 5 and above for new enum structures