PHP 8.1 有了 readonly,8.4 又補上 property hooks,不可變物件的條件其實早就到位了,但實務上每天寫資料物件,還是會一直撞到同一批小摩擦。
new Order($date, $time),字串順序寫反會導致難以發現錯誤。readonly 很好,但想改欄位、建構新物件時得整包重建。with(['items' => [['count' => 1]]]) 會讓原本 items 裡的其他東西丟失。null 欄位從輸出拿掉,每次都得手動 array_filter 清一遍。
每一條單獨看都不是大事,但把它們放在一起,會發現背後其實是同一件我想守住的事:一個物件只要存在,就必然是合法的,不存在「建好了卻處在非法狀態」這種可能。
這個立場在型別驅動設計裡有不少名字:always-valid domain model、make illegal states unrepresentable、parse, don't validate,講的都是同一件事,本質上都是 PHP 沒有現成工具可以乾淨地守住它。
所以我做了 ImmutableBase,一個零依賴、不綁任何框架的 PHP 套件,嚴守「存在即合法」原則建構不可變資料物件,涵蓋 Data Transfer Object(DTO)、Value Object(VO)、Single Value Object(SVO)三種,選擇支援 PHP 8.4+ 是因為 readonly 加上 property hooks 剛好讓「不可變 + 建構時驗證 + 型別收斂」這三件事能乾淨地湊在一起,這是刻意的選擇。
composer require reallifekip/immutable-base
需要 PHP 8.4+
直接丟 array 或 JSON 進去建構,輸入欄位的順序無所謂:
// ImmutableBase:不用 boilerplate constructor
readonly class Order extends DataTransferObject
{
public string $date;
public string $time;
}
Order::fromArray($data); // 傳 array
Order::fromJson($json); // 傳 JSON 字串
傳統寫法得自己寫 constructor,而且沒辦法直接吃外部 array 或 JSON,參數沒寫名稱還有寫反的風險:
class Order
{
public function __construct(
public readonly string $date,
public readonly string $time,
) {}
}
new Order('2026-01-01', '00:00:00'); // 順序錯了型別也抓不到
這段我覺得值得單獨拉出來講,因為很多人卡在這三個的界線上,它們的差別不在「長得像不像」,在建構時要不要驗證、以及包的是結構還是單一值:
validate(),建構時也不會被呼叫。validate()。$value,把「單一一個值」包起來、賦予語義,例如用 ValidAge 包一個 int,建構時一樣自動驗證, validate()、from()、__toString()、__invoke() 全都只對唯一的 $value 運作。// SVO:包一個 int,並賦予「合法年齡」的語義
readonly class ValidAge extends SingleValueObject
{
public int $value;
public function validate(): bool
{
return $this->value >= 18;
}
}
// VO:有結構、有語義,建構時自動驗證
readonly class User extends ValueObject
{
public string $name;
public ValidAge $age;
public function validate(): bool
{
return mb_strlen($this->name) >= 2;
}
}
// DTO:純載體,不驗證
readonly class SignUpUsersDTO extends DataTransferObject
{
#[ArrayOf(User::class)]
public array $users;
public int $userCount;
}
SignUpUsersDTO 裡的 users 用 #[ArrayOf(User::class)] 標記成「一個裝 User 的陣列」,建構時每個元素都會自動實例化或驗證,array 與 JSON 字串混著傳都吃得下。
補預設值有兩種機制,#[Defaults] 寫在屬性上、吃常數運算式;defaultValues() 是動態的、可回傳物件或 Enum,優先級比 #[Defaults] 高:
readonly class CreateUserDTO extends DataTransferObject
{
public string $name;
#[Defaults('member')]
public string $role;
public static function defaultValues(): array
{
return ['role' => 'admin']; // 優先於 #[Defaults]
}
}
CreateUserDTO::fromArray(['name' => 'Kip']); // role = 'admin'
有一個我特別在意的細節:「沒給」跟「明確給 null」是兩件事。 key 不存在才會套預設值;明確傳了 null,那就尊重 null:
Config::fromArray([]); // theme = 'dark'(key 缺 → 套預設)
Config::fromArray(['theme' => null]); // theme = null(明確 null → 尊重)
想更新巢狀結構最裡面那一格,用 dot path 直接點進去,其他元素原封不動:
$order->with(['items.0.count' => 1]); // dot notation
$order->with(['items[0].count' => 1]); // bracket notation 也行
with() 一律回傳新的物件,原物件永遠不動,這是 immutable 的本分。
VO 跟 SVO 的 validate() 是可選的,建構時整條繼承鏈會由上而下自動走一遍;想反過來從自己往上走,掛 #[ValidateFromSelf],驗證失敗想帶自訂訊息,用 #[Spec]:
// 父類:所有 User 名字都必須符合長度規範
readonly class User extends ValueObject
{
public string $name;
public int $level;
public function validate(): bool
{
return mb_strlen($this->name) >= 2;
}
}
// 子類:額外要求等級必須大於等於 2
// 不用重寫父類規則,規則自動疊加並生效
readonly class VIP extends User
{
public function validate(): bool
{
return $this->level >= 2;
}
}
// 建構 VIP 時,User::validate() 與 VIP::validate() 會疊加,必須通過兩條驗證規則
每層只負責自己的約束,父類規則不用在子類重寫一遍,整條鏈合起來才是這個物件完整的合法性定義,這才是真正解掉『驗證散落』的關鍵,讓規則各歸各層、自動合成。
為什麼這樣設計?在我看來,繼承概念就該和現實世界一樣,以代碼範例為例說明,VIP是會員的一種,那麼理所當然就必須同樣遵守會員的建構規則。
SVO 強制必須宣告 $value,但型別自己定,父類別不會把型別鎖死成 mixed 或一大包 union,寫 int 就是 int,語義對齊,這是用 interface + hooked property 做到的,沒有 reflection 開銷:
readonly class ValidAge extends SingleValueObject
{
public int $value; // 語義正確的型別
}
用 #[SkipOnNull] 把 null 欄位從 toArray() / toJson() 拿掉,再用 #[KeepOnNull] 針對單一屬性反悔保留,不用每次手動過濾:
#[SkipOnNull]
readonly class User extends ValueObject
{
#[KeepOnNull]
public ?string $name; // null 也保留
public ?int $age; // null 就不輸出
}
User::fromArray([])->toArray(); // ['name' => null]
預設靠 reflection 掃描屬性 metadata,這會導致每個 request 都付一次沉重的成本,然而執行 vendor/bin/ib-cacher 產出 ib-cache.php 後將改為啟動直接讀快取,跳過反射掃描:
vendor/bin/ib-cacher
vendor/bin/ib-writer 會掃過專案裡所有 ImmutableBase 子類別,生出 Mermaid 類別圖、Markdown 屬性表、跟 TypeScript 宣告,讓文件跟著程式碼走、不會對不上。
巢狀建構出錯時,例外訊息會帶完整的屬性路徑,而不是丟下一句模糊的話讓你自己找,例外示意:
SomeException: Order > $profile > 0 > $count > {錯誤訊息}

ImmutableBase 想解決的,就是 PHP 寫不可變資料物件時那一整批反覆出現的小摩擦:不用手寫 constructor、宣告式預設值、深層路徑更新、自動驗證鏈、型別收斂、輸出控制,但說到底這些功能都是為了同一件事,讓一個物件只要存在就一定合法。
不管是 ImmutableBase 的使用回饋、PHP / Domain-Driven Design / Clean Architecture 的設計取捨,還是不同語言對不可變的設計哲學、不同架構對值物件的應用與落地手法,也都很歡迎交流!