Skip to content
Muhammet Şafak
tr
Languages 5 min read

PHP 8.1 Is Coming: Safer Domain Models with Enums and Readonly Properties

PHP 8.1 isn't out yet, but I'm already exploring how the upcoming native enum support and readonly properties will reshape domain modelling.


PHP 8.1 is expected to drop sometime around December. I’ve been following the release candidates, and two features in particular have caught my attention: native enum support and readonly properties. Together, they open the door to safer domain models at the language level. I can’t use them in production just yet, but I want to think concretely about what they’ll bring before the release lands.

Why these two features matter

PHP 8.0 introduced meaningful additions like union types, match expressions, and named arguments. 8.1 goes further and directly impacts the domain modelling layer.

There are two recurring problems in any domain model:

The first is representing a field’s valid values as an open-ended string or int. Order status, user role, payment type — these are closed sets. Until now, PHP had to represent them through class constants or third-party packages.

The second is the inability to protect fields that must not change after an object is created. To keep value objects immutable you had to either avoid writing setters after the constructor, or block them via __set — both approaches are tedious.

Native enum: closed sets at the language level

PHP 8.1’s native enum is quite different from class constants. It creates a genuine type:

<?php

enum OrderStatus
{
    case Pending;
    case Processing;
    case Shipped;
    case Cancelled;
}

You can now use OrderStatus directly in a function signature:

<?php

function processOrder(Order $order, OrderStatus $status): void
{
    $order->status = $status;
}

processOrder($order, OrderStatus::Processing);
processOrder($order, 'processing');  // TypeError — at runtime, not compile time

Passing an invalid value simply isn’t possible; the type system prevents it.

Backed enum: When you need to store a string or int value in a database or JSON, use a backed enum:

<?php

enum OrderStatus: string
{
    case Pending    = 'pending';
    case Processing = 'processing';
    case Shipped    = 'shipped';
    case Cancelled  = 'cancelled';
}

// Converting from the database:
$status = OrderStatus::from('pending');      // OrderStatus::Pending
$status = OrderStatus::tryFrom('unknown');   // null, does not throw

Enum methods: Enums can also carry methods:

<?php

enum OrderStatus: string
{
    case Pending    = 'pending';
    case Processing = 'processing';
    case Shipped    = 'shipped';
    case Cancelled  = 'cancelled';

    public function label(): string
    {
        return match($this) {
            OrderStatus::Pending    => 'Pending',
            OrderStatus::Processing => 'Processing',
            OrderStatus::Shipped    => 'Shipped',
            OrderStatus::Cancelled  => 'Cancelled',
        };
    }

    public function isFinal(): bool
    {
        return match($this) {
            OrderStatus::Shipped, OrderStatus::Cancelled => true,
            default => false,
        };
    }
}

Behaviour that I used to write manually inside an OrderStatus class can now live naturally inside the enum itself.

Safe conversion with tryFrom

You can never guarantee that a value read from the database will always be a valid enum case — someone might write directly to the table, or there may be values left over from old migrations. For that reason, preferring tryFrom() over from() is the safer habit:

<?php

$raw = $row['status']; // Raw value from the database

$status = OrderStatus::tryFrom($raw);

if ($status === null) {
    // Unknown value; log and either fall back to a default or throw
    throw new \UnexpectedValueException("Unknown order status: {$raw}");
}

from() throws a ValueError on an unknown value; tryFrom() returns null instead. Which one to use depends on the answer to: “Is this situation an error, or an expected edge case?”

Readonly properties: immutability at the language level

A value object should not change after it’s created. Today, enforcing that requires either making every property private and writing a getter, or resorting to other tricks:

<?php

// Today: writing a getter for every property
class Money
{
    private int $amount;
    private string $currency;

    public function __construct(int $amount, string $currency)
    {
        $this->amount   = $amount;
        $this->currency = $currency;
    }

    public function getAmount(): int   { return $this->amount; }
    public function getCurrency(): string { return $this->currency; }
}

With PHP 8.1 readonly properties:

<?php

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

$money = new Money(100, 'TRY');
echo $money->amount;      // 100 — public, readable
$money->amount = 200;     // Error: Cannot modify readonly property

public readonly means “anyone can read it, but only the constructor may assign it.” An immutable value object, no getters required.

Combining the two

Enum and readonly together make domain models significantly more robust:

<?php

class Order
{
    public function __construct(
        public readonly int $id,
        public readonly int $userId,
        public OrderStatus $status,
        public readonly \DateTimeImmutable $createdAt,
    ) {}
}

$order = new Order(
    id: 1,
    userId: 42,
    status: OrderStatus::Pending,
    createdAt: new \DateTimeImmutable(),
);

$order->status = OrderStatus::Processing; // Valid — status is not readonly
$order->id     = 99;                      // Error — id is readonly

Closing thoughts

These two PHP 8.1 features enable safer models at the language level. Being able to do natively what we previously solved with packages or manual workarounds reduces dependencies and strengthens language alignment.

When the release arrives, I plan to adopt them incrementally: first in newly written models, then in critical domain objects. For enums, I’ll need to audit every place that currently returns class constants — it doesn’t make sense to convert every constant to an enum, but closed sets that represent state are natural candidates for native enums. For readonly, every class I’m using as a value object is an immediate candidate.

The fact that enums can implement interfaces is also worth highlighting. If all notification types in a notification system need to share a common behaviour, an enum can enforce that through an interface — declaring at the language level that every member of a closed set honours the same contract, without building a class hierarchy. That’s a clear sign PHP’s type system is maturing.

Tags: #PHP
Share:

Comments

Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.

Related Posts

Search the site

Start typing to search posts, projects and pages.

Esc to close Powered by Pagefind