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.
Table of Contents
- 1. Why plain enums often aren't enough
- 2. Enums implement interfaces like any other class
- 3. Defining your own methods directly in the enum
- 4. Implementing multiple interfaces at once
- 5. Static methods and factory patterns in enums
- 6. match expressions in enum methods for case-specific behavior
- 7. Enums with interfaces as a lightweight strategy pattern replacement
- 8. Type safety via interfaces as parameter and return types
- 9. Limits and pitfalls: no properties, no inheritance
- 10. Summary
- 11. FAQ
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?
2Can an enum inherit from another enum?
3Method in the enum vs. external helper function?
4Implement multiple interfaces at once?
5Call an interface method on a case?
6New case, match() doesn't cover it?
7Use an enum as an interface typehint?
8Can enums use traits?
9More performant than a classic class hierarchy?
10Serialize an enum with methods to JSON?
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