PHP 8.1 was released in November 2021. After a month of migrating modules and writing new code with the new features I have a clear view of what matters in practice. Enums are the biggest quality-of-life improvement – I show how I store them in the database, use them in match expressions, and build a Money value object that shows readonly properties and enums working together.
Enums in practice – database storage
<?php
declare(strict_types=1);
enum OrderStatus: string
{
case Pending = 'pending';
case Processing = 'processing';
case Complete = 'complete';
case Cancelled = 'cancelled';
public function label(): string
{
return match($this) {
self::Pending => 'Awaiting payment',
self::Processing => 'In progress',
self::Complete => 'Completed',
self::Cancelled => 'Cancelled',
};
}
public function canTransitionTo(self $new): bool
{
return match($this) {
self::Pending => in_array($new, [self::Processing, self::Cancelled]),
self::Processing => in_array($new, [self::Complete, self::Cancelled]),
self::Complete,
self::Cancelled => false, // final states
};
}
}
// Storing in the database - use ->value
$statusValue = OrderStatus::Processing->value; // 'processing'
// Reading from the database
$fromDb = OrderStatus::from($row['status']); // throws on invalid value
$safe = OrderStatus::tryFrom($row['status']); // returns null on invalid
// In Magento ResourceModel
class OrderStatusMapper
{
public function toDb(OrderStatus $status): string
{
return $status->value;
}
public function fromDb(string $value): OrderStatus
{
return OrderStatus::tryFrom($value)
?? throw new \UnexpectedValueException("Invalid status: {$value}");
}
}
Enum in source model for admin dropdowns
<?php
// Magento source model built from enum - zero duplication
class OrderStatusSource implements \Magento\Framework\Data\OptionSourceInterface
{
public function toOptionArray(): array
{
return array_map(
fn(OrderStatus $s) => ['value' => $s->value, 'label' => $s->label()],
OrderStatus::cases()
);
}
}
// Result: [['value' => 'pending', 'label' => 'Awaiting payment'], ...]
Money value object – readonly + enum
<?php
declare(strict_types=1);
enum Currency: string
{
case PLN = 'PLN';
case EUR = 'EUR';
case USD = 'USD';
case GBP = 'GBP';
public function symbol(): string
{
return match($this) {
self::PLN => 'zł',
self::EUR => '€',
self::USD => '$',
self::GBP => '£',
};
}
}
final class Money
{
// readonly: assigned once in constructor, immutable thereafter
public function __construct(
public readonly int $amount, // in smallest unit (pence, grosz)
public readonly Currency $currency,
) {
if ($amount < 0) {
throw new \InvalidArgumentException('Amount cannot be negative');
}
}
public function add(self $other): self
{
$this->assertSameCurrency($other);
return new self($this->amount + $other->amount, $this->currency);
}
public function subtract(self $other): self
{
$this->assertSameCurrency($other);
if ($other->amount > $this->amount) {
throw new \InvalidArgumentException('Result would be negative');
}
return new self($this->amount - $other->amount, $this->currency);
}
public function multiply(float $factor): self
{
return new self((int) round($this->amount * $factor), $this->currency);
}
public function applyTax(float $rate): self
{
return $this->multiply(1 + $rate);
}
public function applyDiscount(float $percent): self
{
return $this->multiply(1 - ($percent / 100));
}
public function format(): string
{
return number_format($this->amount / 100, 2, ',', ' ')
. ' ' . $this->currency->symbol();
}
public function equals(self $other): bool
{
return $this->amount === $other->amount
&& $this->currency === $other->currency;
}
private function assertSameCurrency(self $other): void
{
if ($this->currency !== $other->currency) {
throw new \InvalidArgumentException(
"Currency mismatch: {$this->currency->value} vs {$other->currency->value}"
);
}
}
}
// Usage
$price = new Money(9999, Currency::PLN); // 99.99 PLN
$shipping = new Money(1499, Currency::PLN); // 14.99 PLN
$total = $price->add($shipping); // 114.98 PLN
$withTax = $total->applyTax(0.23); // 141.43 PLN
$final = $withTax->applyDiscount(10); // 127.29 PLN (10% off)
echo $final->format(); // 127,29 zł
Readonly on existing PHP 7.x patterns
<?php
// Before PHP 8.1 - private property + getter
class ProductId
{
private int $value;
public function __construct(int $value)
{
if ($value <= 0) throw new \InvalidArgumentException('ID must be positive');
$this->value = $value;
}
public function getValue(): int { return $this->value; }
public function equals(self $other): bool { return $this->value === $other->value; }
}
// PHP 8.1 - public readonly eliminates the getter
class ProductId
{
public readonly int $value;
public function __construct(int $value)
{
if ($value <= 0) throw new \InvalidArgumentException('ID must be positive');
$this->value = $value;
}
public function equals(self $other): bool { return $this->value === $other->value; }
}
$id = new ProductId(42);
echo $id->value; // 42 - direct access, no getter needed
// $id->value = 99; // Error: Cannot modify readonly property
Summary
PHP 8.1 after a month in production proves its worth daily. Enums eliminate the “class full of constants” antipattern and bring type safety to state machines. Readonly properties with constructor promotion reduce value object boilerplate to almost nothing. The Money + Currency example shows how these two features reinforce each other – an immutable, type-safe financial value object in about 50 lines.
