Skip to content
Tyrone Software
PHP Coding

PHP enums and readonly: what they fix and where they bite

Abstract illustration for the article PHP enums and readonly: what they fix and where they bite

Two features from PHP 8.1 remove whole categories of bugs in domain code: enums, which replace strings that stand for a fixed set of values, and readonly properties, which stop objects from changing after they are built. They also have rules that catch people who arrive from other languages.

Enums

A backed enum ties each case to a scalar value:

enum Status: string
{
    case Pending = 'pending';
    case Paid    = 'paid';
    case Failed  = 'failed';

    public function isFinal(): bool
    {
        return $this !== self::Pending;
    }
}

Type declarations can now say Status $status, and no other value gets in, unlike a string parameter that accepts "paid " with a typo. Status::from('paid') returns the case and throws a ValueError for an unknown value, while Status::tryFrom('x') returns null, which suits input that may be invalid. Status::cases() lists all cases for a select box or validation.

Table of PHP backed enum from, tryFrom and cases calls with their results
Use tryFrom for user input and from when an invalid value is a bug.

The rules to remember:

  • Cases are singletons, so compare with ===. There is no new Status.
  • Enums can have methods, constants and static methods, and can implement interfaces, but they cannot hold state: no properties.
  • A pure enum (no backing value) has no from or tryFrom.
  • Serialising a backed enum to JSON with json_encode gives its value, which is convenient for APIs.
  • Laravel's Eloquent can cast a column to an enum (protected $casts = ['status' => Status::class]), and Symfony and Doctrine support them as well.

Readonly properties

final class Money
{
    public function __construct(
        public readonly string $amount,
        public readonly string $currency,
    ) {}
}

A readonly property can be initialised once, and only from inside the class scope. Afterwards, any write throws an Error. There are conditions that surprise:

  • The property must have a type declaration, and it cannot have a default value.
  • Readonly is shallow. If a readonly property holds an object or an array, the properties of that object can still change through the reference, though the property itself cannot be reassigned.
  • Cloning is awkward. You cannot change a readonly property on a clone from the outside, which is why "with-er" methods usually build a new object through the constructor. PHP 8.3 relaxed this by allowing re-initialisation inside __clone, so check the version you support.
  • PHP 8.2 added readonly class, which makes all its properties readonly.
  • Frameworks that hydrate objects by setting properties directly, or that create proxies (some ORM lazy loading), may not cooperate with readonly. Test the specific combination before rolling it out through your entities.

Where they belong

Use enums for statuses, types, roles and modes that exist in code and in a database column. Use readonly for value objects such as money, dates ranges, coordinates and commands passed between layers. Both remove defensive code (if (!in_array($status, [...]))), and the type system becomes the place where invalid states are impossible.

Attaching data to a case through an interface method

Enums cannot hold per-case properties, which is a deliberate restriction, but a match expression inside a method gives you the same result for read-only, per-case data such as a display label or a colour, without breaking the singleton and type-safety guarantees above:

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

enum Status: string implements HasLabel
{
    case Pending = 'pending';
    case Paid    = 'paid';
    case Failed  = 'failed';

    public function label(): string
    {
        return match ($this) {
            self::Pending => 'Awaiting payment',
            self::Paid    => 'Paid',
            self::Failed  => 'Payment failed',
        };
    }
}

The match has no default arm on purpose: adding a new case to the enum without adding it here makes the method throw UnhandledMatchError immediately, which is a deliberate trade against silently falling through to a wrong label, and is usually preferable to a default that hides the omission until someone notices the display text is wrong. UnitEnum and BackedEnum are the two built-in interfaces every enum implements automatically (pure and backed respectively), and are useful as type hints when a function should accept "any enum case" rather than one specific enum type.

Keep reading

Technologies

  • Python
  • PHP
  • Laravel
  • Django
  • PostgreSQL
  • MySQL
  • HTML5
  • CSS3
  • Bootstrap