PHP Enum Methods and Interfaces — Beyond Basic Backed Enums

PHP enums can do far more than store a value. Add methods, implement interfaces, and replace entire class hierarchies with the PHP enum strategy pattern.

Steven Richardson
Steven Richardson
· 6 min read

If you're using PHP enums purely to store a string or integer value, you're leaving most of the feature on the table. The backed enums in Laravel models article covers the basics — casting, Eloquent, why you should ditch string constants. This picks up where that left off: methods, interfaces, and the strategy pattern.

Methods on Enums#

Any enum — pure or backed — can define methods. The $this variable refers to the specific case, so you can use match($this) to return case-specific values.

The classic use case is display logic. Instead of scattering match ($order->status) expressions across your Blade templates, controllers, and API transformers, put it on the enum:

enum OrderStatus: string
{
    case Pending   = 'pending';
    case Paid      = 'paid';
    case Shipped   = 'shipped';
    case Cancelled = 'cancelled';

    public function label(): string
    {
        return match ($this) {
            self::Pending   => 'Awaiting Payment',
            self::Paid      => 'Payment Received',
            self::Shipped   => 'On its Way',
            self::Cancelled => 'Cancelled',
        };
    }

    public function color(): string
    {
        return match ($this) {
            self::Pending   => 'yellow',
            self::Paid      => 'blue',
            self::Shipped   => 'green',
            self::Cancelled => 'red',
        };
    }
}

Now your template becomes {{ $order->status->label() }} and {{ $order->status->color() }} — no conditional blocks, no helper functions. The enum owns its own presentation.

Methods can also encapsulate business rules:

public function canTransitionTo(self $next): bool
{
    return match ($this) {
        self::Pending   => $next === self::Paid || $next === self::Cancelled,
        self::Paid      => $next === self::Shipped,
        self::Shipped   => false, // terminal state
        self::Cancelled => false, // terminal state
    };
}

Calling $order->status->canTransitionTo(OrderStatus::Shipped) reads clearly and keeps all transition logic in one place.

Implementing Interfaces on Enums#

Enums can implement interfaces, and every case satisfies the interface contract. On a backed enum the syntax is enum Foo: string implements Bar — the backing type comes first, then implements.

This is useful when you want to accept either a class or an enum case as the same type:

interface HasBadge
{
    public function badgeText(): string;
    public function badgeColor(): string;
}

enum SubscriptionTier: string implements HasBadge
{
    case Free    = 'free';
    case Pro     = 'pro';
    case Enterprise = 'enterprise';

    public function badgeText(): string
    {
        return match ($this) {
            self::Free       => 'Free',
            self::Pro        => 'Pro',
            self::Enterprise => 'Enterprise',
        };
    }

    public function badgeColor(): string
    {
        return match ($this) {
            self::Free       => 'gray',
            self::Pro        => 'indigo',
            self::Enterprise => 'gold',
        };
    }
}

Any function typed as HasBadge now accepts any SubscriptionTier case directly. You could also have a CustomBadge class that implements HasBadge and pass it alongside enum cases through the same code path — useful for feature-flag experiments or admin overrides.

This pairs well with Laravel 13's #[Attribute] syntax if you're building attribute-driven pipelines where values need to carry metadata.

The Enum Strategy Pattern#

The strategy pattern traditionally requires an interface and multiple implementing classes. With enums you can collapse all of that into a single type, because each case can implement the interface contract differently through its match.

Consider a notification system where each channel handles dispatch differently:

interface NotificationChannel
{
    public function dispatch(string $message, string $recipient): void;
}

enum Channel: string implements NotificationChannel
{
    case Email = 'email';
    case Sms   = 'sms';
    case Slack = 'slack';

    public function dispatch(string $message, string $recipient): void
    {
        match ($this) {
            self::Email => $this->sendEmail($message, $recipient),
            self::Sms   => $this->sendSms($message, $recipient),
            self::Slack => $this->sendSlack($message, $recipient),
        };
    }

    private function sendEmail(string $message, string $recipient): void
    {
        // Mail::to($recipient)->send(new GenericMail($message));
    }

    private function sendSms(string $message, string $recipient): void
    {
        // app(SmsClient::class)->send($recipient, $message);
    }

    private function sendSlack(string $message, string $recipient): void
    {
        // Http::post(config('services.slack.webhook'), ['text' => $message]);
    }
}

Calling code becomes simply:

$channel->dispatch($message, $recipient);

No factory, no if/elseif chain, no abstract base class. When you add a new channel, PHP's exhaustive match will throw UnhandledMatchError at runtime if you forget to handle it in dispatch() — and PHPStan at level 10 will flag the non-exhaustive match at static analysis time, before you ever ship.

Exhaustive Match Expressions#

One of the most valuable properties of enums in match expressions is exhaustiveness. Unlike switch, a match with no default will throw \UnhandledMatchError if none of the arms match. When the subject is an enum, PHP can only receive a valid case — so if you cover every case, the match is truly exhaustive and no default is needed:

$icon = match ($status) {
    OrderStatus::Pending   => 'clock',
    OrderStatus::Paid      => 'credit-card',
    OrderStatus::Shipped   => 'truck',
    OrderStatus::Cancelled => 'x-circle',
};

Add a new case to OrderStatus and forget to update this match? PHPStan catches it. The PHP 8.5 pipe operator plays nicely with this pattern too — if the final step of a pipeline returns an enum, you can match exhaustively on the result without defensive defaults cluttering the code.

Gotchas and Edge Cases#

Enums cannot be extended. You can't subclass an enum or override case behaviour per-subclass. If you need runtime variation beyond what a method can provide, that's a sign you actually want a class hierarchy — not an enum. Interfaces let you mix enums into class hierarchies from the outside, but the enum itself stays closed.

Private methods are allowed but protected is pointless. Enums can't be extended so protected behaves identically to private. Stick with public and private.

Constructor promotion doesn't apply. Enums have no constructor you can override. If you need to store arbitrary per-instance data, that's a value object — consider a readonly class instead.

match arms must be case-identical. self::Paid and self::paid are different things; PHP enums are case-sensitive. IDE autocompletion helps here, or reach for a strict PHPStan setup to catch typos early.

Laravel's service container can't resolve enum cases by type. You can bind an interface and resolve it, but you can't do app(Channel::class) and expect a specific case back. If you need to inject a specific strategy, pass the enum case explicitly or bind via a tagged closure.

Wrapping Up#

PHP enums are a proper type system feature, not just a nicer constant. Methods colocate logic where it belongs. Interfaces make enum cases first-class participants in your type hierarchy. The strategy pattern lets you delete entire class hierarchies and replace them with something that fits on one screen.

If you're still at the "backed enum in an Eloquent model" stage, read the backed enums in Laravel models primer first, then come back here. Once you're comfortable with methods and interfaces on enums, PHP 8.4 property hooks are the next modern PHP feature worth reaching for.

FAQ#

Can PHP enums have methods?

Yes. Both pure enums and backed enums can define public, private, or protected methods. Inside a method, $this refers to the enum case instance, so you can use match($this) to return case-specific values. Methods are the right place for display logic like label() or color(), and for business rules like state-transition guards.

How do I implement an interface on a PHP enum?

Use the standard implements keyword after the enum declaration. For backed enums, the backing type comes first: enum Foo: string implements BarInterface. Every case of the enum must satisfy the interface contract — the methods you define on the enum body fulfill it. Any type-check against the interface will accept all enum cases automatically.

What is the strategy pattern with PHP enums?

It's the technique of replacing a family of strategy classes (an interface plus one class per variant) with a single enum that implements the interface. Each case's behaviour is defined by match($this) inside the interface method. You get exhaustiveness checking for free — add a case and forget to update the match, and PHP throws UnhandledMatchError at runtime while PHPStan flags it at static analysis time.

Can PHP enums be used with Laravel's service container?

Not as resolvable bindings in the traditional sense — you can't bind an interface to an enum type and have the container resolve a specific case. What you can do is bind an interface to a closure that returns the right enum case based on config or context, or pass the desired case explicitly as a constructor argument. The most common approach is to keep enum cases as typed method parameters and let the caller decide which case to pass.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.