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.
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.