Skip to content
System nominalUTC --:--:--Articles 041
MODULE 02 Lesson 06 / 15 30 MIN

Types and data

Enums, readonly Properties and match

Backed and pure enums, from and tryFrom, what readonly means for a value object, and why match is stricter than switch.

A template prints the order status by running ucfirst($order->status) and works fine until somebody adds a status the label logic never saw. A database column holds 'express', a shipping service compares it with 'Express', and the mismatch costs a week of support tickets. A discount rule is written as a switch with no default, and a new enum case falls straight through it in silence.

Those are all the same failure at different depths: a valid set of values exists, and nothing in the code states what it is. PHP 8.1 gave the language two tools for saying it out loud, enums and readonly properties, and PHP 8.0 added a third, match, that refuses to guess. Learn them together because they solve the same class of problem and combine well: an enum names the states, a readonly value object carries one, and a match turns the state into behaviour with the compiler watching.

Everything below ran on PHP 8.5.6 and the code targets PHP 8.3 and newer. The error messages and output are verbatim, with paths shortened.

A backed enum turns a string column into a closed set

Start with the shape that replaces a column of magic strings. Each case declares a backing value, and the value is the only part that belongs in storage or in a request.

<?php
declare(strict_types=1);

enum Shipping: string implements HasLabel
{
    case Standard = 'standard';
    case Express = 'express';
    case Overnight = 'overnight';

    public function label(): string
    {
        return match ($this) {
            self::Standard => 'Standard (3-5 days)',
            self::Express => 'Express (next day)',
            self::Overnight => 'Overnight',
        };
    }
}

var_dump(Shipping::Express->value);
var_dump(Shipping::Express->name);
var_dump(Shipping::Express->label());
var_dump(array_map(static fn (Shipping $s): string => $s->value, Shipping::cases()));
string(7) "express"
string(7) "Express"
string(17) "Express (next day)"
array(3) {
  [0]=>
  string(8) "standard"
  [1]=>
  string(7) "express"
  [2]=>
  string(9) "overnight"
}

Three ways of naming the same case are visible there and they are not interchangeable. ->value is the string that goes to the database or the API. ->name is the identifier in the source code, Express, which you want in logs and error messages because it is what a developer will search for. ->label() is yours, and it is where the presentation string lives instead of in a template that has to guess. Shipping::cases() returns every case in declaration order, so a select element, a validation rule and a test fixture can all be generated from the same list.

A backed enum can use string or int as its type and cannot mix the two across cases. For a column that already stores integers, an int-backed enum is the smaller change, and it makes the storage contract explicit in the type rather than in a comment. A pure enum declares no backing value at all:

<?php
declare(strict_types=1);

enum Colour
{
    case Red;
    case Green;
}

var_dump(Colour::Red === Colour::Red);
var_dump(Colour::Red instanceof Colour);
var_dump(gettype(Colour::Red));
bool(true)
bool(true)
string(6) "object"

The third line is the one that changes how you design with these. An enum case is an object, not an integer or a string. Colour::Red === Colour::Red is true because there is exactly one instance of each case for the lifetime of the process, so identity comparison is safe and is what the engine itself uses inside a match. A pure enum has no ->value property, which makes it the right choice for states that are never serialised: a parser mode, a job phase, the result of an internal comparison.

from throws and tryFrom returns null, and that decides where you validate

Input arrives as a string. Converting it to a case is one call, and the two available calls differ in what happens when the value is not in the set.

<?php
declare(strict_types=1);

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

var_dump(Status::from('published'));
var_dump(Status::tryFrom('nope'));
var_dump(Status::tryFrom('NOPE'));

try {
    Status::from('nope');
} catch (ValueError $e) {
    echo $e::class, ': ', $e->getMessage(), PHP_EOL;
}

try {
    Status::from(5);
} catch (TypeError $e) {
    echo $e::class, ': ', $e->getMessage(), PHP_EOL;
}
enum(Status::Published)
NULL
NULL
ValueError: "nope" is not a valid backing value for enum Status
TypeError: Status::from(): Argument #1 ($value) must be of type string, int given

from() returns the case or throws ValueError, and the message names the offending value and the enum, which is enough to debug a bad row without opening the database. tryFrom() returns the case or null, and the two NULL lines carry a detail worth noticing: matching is case sensitive, so 'NOPE' and 'nope' both fail and 'NOPE' would fail even if nope were a case. Normalise case before the lookup rather than after.

Which one to call is a question about who owns the failure. Status::from($row['status']) is right when the value came from a column you control, because a value outside the set means the data is corrupt and you want the request to stop with a stack trace that points at the row. tryFrom() is right at a request boundary, where a user can type anything and a missing method is a validation error rather than a bug:

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

    public static function fromRequest(string $raw): self
    {
        return self::tryFrom(strtolower($raw))
            ?? throw new ValueError("Unknown role: {$raw}");
    }
}

That pattern keeps the failure in one place. The caller passes whatever arrived, the enum decides whether it is valid, and the exception message names the input instead of the line inside the enum. The ?? throw form works because throw is an expression in PHP 8, so it can sit on the right of a null coalescing operator.

Backed enums serialise, pure enums do not

json_encode() renders a backed case as its backing value, so Status::Draft becomes "draft" without help. A pure enum has no default serialisation at all: json_encode(Colour::Red) returns false, and with JSON_THROW_ON_ERROR it fails with JsonException: Non-backed enums have no default serialization. Give the enum a JsonSerializable implementation if it has to cross that boundary.

Enums carry methods, interfaces and constants

An enum is a class with a fixed set of instances, so it can hold behaviour. Moving the derived values onto the cases removes the switch statements that would otherwise live in a service, and each one gets checked for completeness by the analyser.

<?php
declare(strict_types=1);

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

enum Shipping: string implements HasLabel
{
    case Standard = 'standard';
    case Express = 'express';
    case Overnight = 'overnight';

    public function label(): string
    {
        return match ($this) {
            self::Standard => 'Standard (3-5 days)',
            self::Express => 'Express (next day)',
            self::Overnight => 'Overnight',
        };
    }

    public function rateCents(int $weightGrams): int
    {
        $base = match ($this) {
            self::Standard => 495,
            self::Express => 1250,
            self::Overnight => 2490,
        };

        $extraKilos = intdiv(max(0, $weightGrams - 1000) + 999, 1000);

        return $base + intdiv($base * 15 * $extraKilos, 100);
    }
}

function quote(Shipping $shipping, int $weightGrams): int
{
    return $shipping->rateCents($weightGrams);
}

foreach (Shipping::cases() as $method) {
    printf("%-24s %d cents\n", $method->label(), quote($method, 2500));
}
Standard (3-5 days)      643 cents
Express (next day)       1625 cents
Overnight                3237 cents

The interface is what makes the enum usable behind a boundary. A function that only needs a display string can accept HasLabel and stop caring about shipping, which keeps the dependency pointing at the smallest contract that does the job. Within the enum, $this inside a method is the case itself, so a match ($this) with one arm per case is exhaustive by construction, and adding a fourth case makes the analyser in module 5 point at every method that needs a new arm.

Two restrictions are worth knowing before you design around them. An enum cannot be extended, so there is no subclass to add a case:

PHP Fatal error:  Class Extended cannot extend enum Status in /path/to/extended.php on line 4

And a duplicate backing value is not rejected when the file is compiled, which is worse than a compile error, because the failure arrives at the lookup instead. Reading such an enum with from() throws Error: Duplicate value in enum Status for cases Draft and AlsoDraft, and the same file declares both cases without complaint. Treat a backing value as a primary key and keep it unique by convention, because the engine will not enforce it until somebody reads the enum.

readonly makes an object a value rather than a record

A value object is a small type whose identity is its contents. Two Money instances holding 1999 cents and EUR are the same amount, and neither of them should ever change. readonly is the language feature that enforces the second half of that sentence.

<?php
declare(strict_types=1);

final readonly class Money
{
    public function __construct(
        public int $cents,
        public string $currency = 'EUR',
    ) {
        if ($cents < 0) {
            throw new InvalidArgumentException('Money cannot be negative');
        }
    }

    public function plus(Money $other): self
    {
        if ($other->currency !== $this->currency) {
            throw new InvalidArgumentException('Currency mismatch');
        }

        return new self($this->cents + $other->cents, $this->currency);
    }

    public function format(): string
    {
        return sprintf('%.2f %s', $this->cents / 100, $this->currency);
    }
}

$price = new Money(1999);
$tax = new Money(420);
$total = $price->plus($tax);

echo $price->format(), ' + ', $tax->format(), ' = ', $total->format(), PHP_EOL;
echo 'price unchanged: ', $price->format(), PHP_EOL;

try {
    $price->cents = 0;
} catch (Error $e) {
    echo $e::class, ': ', $e->getMessage(), PHP_EOL;
}

try {
    new Money(-1);
} catch (InvalidArgumentException $e) {
    echo $e::class, ': ', $e->getMessage(), PHP_EOL;
}
19.99 EUR + 4.20 EUR = 24.19 EUR
price unchanged: 19.99 EUR
Error: Cannot modify readonly property Money::$cents
InvalidArgumentException: Money cannot be negative

plus() returns a new instance and the original keeps its value, which is the whole reason to make the property readonly. Without it, a method that mutates $this and returns self forces every caller to ask whether their variable still holds what it held before the call. With it, the answer is always yes, and the code that shares one Money between two order lines cannot develop a bug where one line changes the other's amount.

The two exceptions tell you where each check runs. The write attempt fails with Error: Cannot modify readonly property Money::$cents at the moment of assignment, and the class name in the message tells you which object was mutated. The constructor guard throws InvalidArgumentException while the object is being built, so an invalid Money cannot exist at all. A value object that validates in its constructor turns a whole category of downstream checks into code you do not have to write.

Three limits decide whether this fits your type. A readonly property may be written once from inside the declaring class, which is how a property assigned in a body rather than promoted behaves; a second write fails the same way. Readonly is shallow, so an array held in a readonly property cannot be modified through the property, but an object held in one can have its own public properties changed, and if that object is mutable the value object stops being immutable in practice. Readonly properties also cannot be static. The implementation of promotion, readonly classes and inheritance belongs to the module on classes; what matters here is the contract a value object offers its callers.

match compares strictly, returns a value, and throws when nothing matches

match is an expression, so it produces a value instead of falling into the next arm, and its comparison is === rather than the loose comparison switch uses. Those two differences explain every surprise it causes.

<?php
declare(strict_types=1);

$code = '1';

switch ($code) {
    case 1:
        echo "switch matched int 1\n";
        break;
    default:
        echo "switch did not match\n";
}

try {
    echo match ($code) {
        1 => "match matched int 1\n",
    };
} catch (UnhandledMatchError $e) {
    echo $e::class, ': ', $e->getMessage(), PHP_EOL;
}

var_dump('1' == 1);
var_dump('1' === 1);
var_dump(match (0) { false => 'loose', 0 => 'strict' });
switch matched int 1
UnhandledMatchError: Unhandled match case '1'
bool(true)
bool(false)
string(6) "strict"

The same string value takes two different paths. switch converts '1' to an integer and matches, which is the behaviour that has produced a generation of comparison bugs. match compares the string with the integer, finds them not identical, and throws. The last line shows the same strictness from the other side: 0 matches the 0 arm and not the false arm, because 0 === false is false even though 0 == false is true. When you write a match over mixed input, that strictness is the feature you came for.

The error class is the other half of the design. match has no implicit default, so an unmatched subject throws rather than returning null and letting the problem travel. Since UnhandledMatchError extends Error and not Exception, it is uncaught unless you catch it deliberately, and the file dies with a message that names the value:

PHP Fatal error:  Uncaught UnhandledMatchError: Unhandled match case Status::Archived in /path/to/colour.php:15
Stack trace:
#0 /path/to/colour.php(19): colour(Status::Archived)
#1 {main}
  thrown in /path/to/colour.php on line 15

That trace is more useful than silence, and it is still a production failure. Two patterns keep it from being one. Add a default arm when the set of values is genuinely open, which tells the reader you thought about the unmatched case, and use match (true) with conditions when the decision is a series of thresholds:

<?php
declare(strict_types=1);

function bucket(int $score): string
{
    return match (true) {
        $score >= 90 => 'A',
        $score >= 80 => 'B',
        $score >= 70 => 'C',
        default => 'F',
    };
}

echo bucket(95), ' ', bucket(85), ' ', bucket(60), PHP_EOL;
A B F

match (true) is a readable replacement for an if/elseif chain and there is no fall-through to reason about, because the first arm whose condition is true wins and the rest are not evaluated. Multiple conditions can share an arm with commas, which is what the label() method avoids needing by listing every case. When the subject is an enum and every case should have an arm, leaving the default out is the better choice: the thrown error is then the signal that somebody added a case and did not finish the job, and a static analyser will say so before the code ships.

Lab 30 minutes

Build shipping.php around a Shipping backed enum with Standard, Express and Overnight cases, each with a rate in cents and a delivery estimate in days. Give the enum a label() method implemented with match ($this) and no default, and a quote(int $weightGrams): Money method that adds a surcharge for every kilogram over the first one using intdiv.

Write a final readonly class Money with integer cents and a currency, a constructor that rejects a negative amount, and a plus() method that returns a new instance. Then write a function quoteFor(string $rawMethod, int $weightGrams): string that converts the request value with tryFrom() and handles a missing method through a match over the result, distinguishing null from every case.

Run it three times: once with 'express', once with 'EXPRESS', and once with 'drone'. Record what happens each time. Then answer two questions. Why does the match need a null arm even though tryFrom() is the call that cannot throw? And what changes if you replace it with from() inside a try/catch?

Show the solution and the reasoning

The decision that shapes this lab is who converts the string into a case. Do it once, at the function boundary, and inside the function every value is a case or null, which is a set the type system can describe.

<?php
declare(strict_types=1);

enum Shipping: string
{
    case Standard = 'standard';
    case Express = 'express';
    case Overnight = 'overnight';

    public function label(): string
    {
        return match ($this) {
            self::Standard => 'Standard (3-5 days)',
            self::Express => 'Express (next day)',
            self::Overnight => 'Overnight',
        };
    }

    public function baseCents(): int
    {
        return match ($this) {
            self::Standard => 495,
            self::Express => 1250,
            self::Overnight => 2490,
        };
    }

    public function quote(int $weightGrams): Money
    {
        $extraKilos = intdiv(max(0, $weightGrams - 1000) + 999, 1000);

        return new Money($this->baseCents() + intdiv($this->baseCents() * 15 * $extraKilos, 100));
    }
}

function quoteFor(string $rawMethod, int $weightGrams): string
{
    $method = Shipping::tryFrom(strtolower($rawMethod));

    return match ($method) {
        Shipping::Standard, Shipping::Express, Shipping::Overnight => sprintf(
            '%s: %s',
            $method->label(),
            $method->quote($weightGrams)->format(),
        ),
        null => "unknown shipping method: {$rawMethod}",
    };
}

echo quoteFor('express', 2500), PHP_EOL;
echo quoteFor('EXPRESS', 2500), PHP_EOL;
echo quoteFor('drone', 2500), PHP_EOL;
Express (next day): 16.25 EUR
Express (next day): 16.25 EUR
unknown shipping method: drone

The null arm is required because of how the comparison works, and this is the detail worth carrying out of the lab. tryFrom() cannot throw, but match does not care how the subject was produced: it needs an arm for whatever value arrives or it raises UnhandledMatchError. Since $method is Shipping|null, an exhaustive match over it has to name null as well as the cases. The null arm is the validation error path, and putting a readable message there means the failure reaches the user as a sentence rather than as a stack trace.

Lowercasing the input before the lookup is what makes the second run work. Without strtolower(), 'EXPRESS' returns null, because backing value comparison is case sensitive. Where you normalise is a design choice: doing it inside the enum keeps every caller honest, and doing it at the request boundary keeps the enum strict. Both are defensible, and the one to avoid is normalising in some call sites and not others.

Swapping tryFrom() for from() inside a try/catch moves the failure from a returned value to a control flow construct. The null arm disappears, which is simpler, and the ValueError becomes indistinguishable from a bug elsewhere in the try block unless the catch is narrow. Use tryFrom() when an unknown value is a normal outcome at that boundary, and from() when the value is supposed to be valid and anything else means your data is corrupt.

Two variations are worth trying. Give Shipping a deliveryDays(): array method returning a range and see how the exhaustiveness of match ($this) behaves when you add a fourth case. Or start the Money class with readonly on the properties but not on the class, and notice that the class-level form removes the option of a mutable field the next person would otherwise add.

Progress

Mark this lesson done and it stays marked in this browser.