Một khách hàng phàn nàn là email nhắc thanh toán tới sớm ba ngày, mà ngày đến hạn in trên hóa đơn cũng bị lùi sớm ba ngày luôn. Thủ phạm là một helper tính ngày nhắc bằng cách gọi modify('-3 days') lên cái DateTime của hóa đơn. DateTime là mutable, object đó lại được xài chung, và một dòng code vô tội đã sửa dữ liệu mà hai chỗ khác trong hệ thống đang dựa vô.
final class Invoice
{
public function __construct(public DateTime $dueDate) {}
}
$invoice = new Invoice(new DateTime('2026-11-30'));
function reminderDate(Invoice $invoice): DateTime
{
return $invoice->dueDate->modify('-3 days');
}
$remindAt = reminderDate($invoice);
echo $invoice->dueDate->format('Y-m-d');
Đoạn đó in ra 2026-11-27. State mutable xài chung là một trong những nguồn bug "ủa sao giá trị này đổi?" lâu đời nhất. PHP có đồ nghề thiệt sự để chặn chuyện này: property readonly (8.1), class readonly (8.2), và từ 8.3 thì được khởi tạo lại property readonly trong __clone. Kết hợp với pattern value object, mình mô hình hóa được tiền, email, khoảng ngày và mấy khái niệm nhỏ khác thành những giá trị không thể bị làm hỏng sau khi đã tạo ra.
Property readonly và class readonly
Property readonly chỉ được khởi tạo đúng một lần, từ bên trong class, và không bao giờ đổi được nữa. Ghi thêm lần nào, từ trong hay ngoài, đều throw Error:
final class Temperature
{
public function __construct(
public readonly float $celsius,
) {}
}
$t = new Temperature(21.5);
echo $t->celsius;
try {
$t->celsius = 30.0;
} catch (Error $e) {
echo $e->getMessage();
}
Mấy quy tắc nên nhớ:
- Property readonly bắt buộc phải có kiểu. Thật sự không muốn ràng buộc thì xài
mixed. - Không được có giá trị mặc định lúc khai báo; giá trị được gán trong constructor (promoted property giúp chuyện này gọn một dòng).
- Chỉ được khởi tạo từ scope của class khai báo. Code bên ngoài cũng không khởi tạo được property readonly chưa có giá trị.
unset()cũng bị cấm một khi property đã được khởi tạo.
Class readonly (PHP 8.2) áp readonly cho mọi property khai báo và cấm luôn dynamic property. Nó không được có property không kiểu hay property static. Với value object, final readonly class gần như lúc nào cũng là khai báo mình cần: mọi thứ bất biến, còn final chặn class con lén thêm state mutable qua cửa sau.
Một giới hạn quan trọng: readonly là nông. Property readonly chứa DateTime thì không gán lại được, nhưng bản thân DateTime vẫn bị sửa được, đúng y cái bug ở trên. Chỉ để đồ bất biến trong property readonly: scalar, array, enum, DateTimeImmutable, và các value object khác.
Value object là gì
Value object là một object nhỏ được định nghĩa hoàn toàn bằng dữ liệu của nó. Hai object Money 100.000 VND thay cho nhau được, giống hai tờ 100 ngàn vậy. Từ đó có một checklist ngắn:
- Bất biến. Thao tác trả về instance mới thay vì sửa instance hiện tại.
- Hợp lệ ngay khi tạo. Constructor từ chối input sai, nên instance nào đã tồn tại thì luôn hợp lệ. Không bao giờ cần
if ($money->isValid()). - So sánh bằng giá trị. Method
equals()so các field, không so danh tính object. - Hành vi nằm trên giá trị.
$price->add($tax)thay vì$price + $taxrải rác khắp controller, mỗi chỗ check tiền tệ một kiểu.
Đây là vòng đời gói trong một hình: input thô được kiểm tra một lần ở biên, từ đó trở đi code chỉ truyền qua lại những giá trị tin được.
Value object Money
Money là ví dụ kinh điển vì bug ở đây tốn tiền thiệt: làm tròn số thực, trộn tiền tệ, và lỡ tay sửa cái giá đang được nhiều dòng trong giỏ hàng xài chung.
namespace App\Support\ValueObjects;
use InvalidArgumentException;
final readonly class Money
{
public function __construct(
public int $amount,
public string $currency,
) {
if (! preg_match('/^[A-Z]{3}$/', $currency)) {
throw new InvalidArgumentException("Invalid currency code [{$currency}].");
}
}
public static function vnd(int $amount): self
{
return new self($amount, 'VND');
}
public function add(self $other): self
{
$this->assertSameCurrency($other);
return new self($this->amount + $other->amount, $this->currency);
}
public function multiply(int|float $factor): self
{
return new self((int) round($this->amount * $factor), $this->currency);
}
public function withAmount(int $amount): self
{
return new self($amount, $this->currency);
}
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("Cannot combine {$this->currency} with {$other->currency}.");
}
}
}
Để ý mấy lựa chọn thiết kế. Amount là số nguyên theo đơn vị nhỏ nhất của tiền tệ (cent với USD; VND không có đơn vị lẻ nên chính là số đồng). Thao tác nào cũng trả new self(...), nên $price->add($tax) để nguyên $price. Trộn tiền tệ thì không thể lỡ tay được. Và vì constructor kiểm tra mã tiền tệ, một Money có currency "vnd " không thể tồn tại ở bất cứ đâu trong hệ thống.
Value object Email
Email nhìn giống chuỗi, nên thường bị lưu bằng chuỗi và được validate theo năm kiểu hơi khác nhau khắp codebase. Bọc nó lại thì chuẩn hóa và kiểm tra nằm chung một chỗ:
namespace App\Support\ValueObjects;
use InvalidArgumentException;
use Stringable;
final readonly class EmailAddress implements Stringable
{
public string $value;
public function __construct(string $value)
{
$normalized = strtolower(trim($value));
if (filter_var($normalized, FILTER_VALIDATE_EMAIL) === false) {
throw new InvalidArgumentException("[{$value}] is not a valid email address.");
}
$this->value = $normalized;
}
public function domain(): string
{
return substr($this->value, strrpos($this->value, '@') + 1);
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
public function __toString(): string
{
return $this->value;
}
}
Ở đây property không promote vì constructor phải chuẩn hóa input trước khi gán. Vậy là hợp lệ: property readonly được khởi tạo ở đâu cũng được trong scope của class khai báo, miễn chỉ một lần. Implement Stringable để object vẫn xài được ở chỗ cần chuỗi như Mail::to((string) $email) hay {{ $email }} trong Blade.
With-er: "sửa" giá trị bằng cách tạo cái mới
Object bất biến thì vẫn cần "chỉnh sửa", ví dụ một khoảng ngày cần kéo dài ra. Quy ước là method with... trả về instance mới với một field đã đổi. Trên PHP 8.3 và 8.4, cách rõ ràng nhất là gọi lại constructor, nhờ vậy validation cũng chạy lại luôn:
final readonly class DateRange
{
public function __construct(
public DateTimeImmutable $start,
public DateTimeImmutable $end,
) {
if ($end < $start) {
throw new InvalidArgumentException('End must not be before start.');
}
}
public function withEnd(DateTimeImmutable $end): static
{
return new static($this->start, $end);
}
public function extendByDays(int $days): static
{
return $this->withEnd($this->end->modify("+{$days} days"));
}
}
Đi qua constructor thì invariant được kiểm tra lại, nên withEnd() không thể tạo ra khoảng ngày kết thúc trước khi bắt đầu.
Class nhiều field thì lặp lại hết tham số constructor hơi mệt. PHP 8.5 thêm "clone with", copy object và ghi đè vài property trong một biểu thức. Nó tôn trọng scope của readonly nên phải nằm trong class:
// PHP 8.5+: clone with, đặt trong class để thỏa quy tắc scope của readonly
public function withEnd(DateTimeImmutable $end): static
{
return clone($this, ['end' => $end]);
}
Lưu ý with-er dựa trên clone sẽ bỏ qua constructor, nên validation không chạy trừ khi mình gọi tường minh. Với value object có invariant, đi qua constructor thường là lựa chọn mặc định an toàn hơn.
Eloquent cast cho value object
Value object phát huy nhất khi nó đi thẳng ra từ model. Một custom cast chuyển qua lại giữa cột và object. Money cần hai cột total_amount và total_currency, và set() được phép trả về mảng để ghi cả hai:
namespace App\Casts;
use App\Support\ValueObjects\Money;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use InvalidArgumentException;
final class MoneyCast implements CastsAttributes
{
public function get(Model $model, string $key, mixed $value, array $attributes): ?Money
{
$amount = $attributes["{$key}_amount"] ?? null;
return $amount === null ? null : new Money((int) $amount, $attributes["{$key}_currency"]);
}
public function set(Model $model, string $key, mixed $value, array $attributes): array
{
if ($value === null) {
return ["{$key}_amount" => null, "{$key}_currency" => null];
}
if (! $value instanceof Money) {
throw new InvalidArgumentException("The [{$key}] attribute must be a Money instance.");
}
return ["{$key}_amount" => $value->amount, "{$key}_currency" => $value->currency];
}
}
namespace App\Models;
use App\Casts\EmailCast;
use App\Casts\MoneyCast;
use Illuminate\Database\Eloquent\Model;
final class Order extends Model
{
protected function casts(): array
{
return [
'total' => MoneyCast::class,
'customer_email' => EmailCast::class,
];
}
}
EmailCast cũng y vậy nhưng một cột: get() trả new EmailAddress($value), set() trả (string) $value. Xài thì giống y attribute bình thường:
use App\Models\Order;
use App\Support\ValueObjects\EmailAddress;
use App\Support\ValueObjects\Money;
$order = new Order();
$order->customer_email = new EmailAddress(' [email protected] ');
$order->total = Money::vnd(450_000);
$order->total = $order->total->add(Money::vnd(30_000));
$order->save();
echo $order->customer_email->domain();
Để ý dòng cộng tiền. Vì Money bất biến, chỉ gọi $order->total->add(...) thì order không đổi gì hết. Phải gán kết quả lại. Với value object mutable, Eloquent cache instance đã cast và đồng bộ thay đổi tại chỗ ngược về attribute trước khi save, nên một chỗ sửa bậy ở đâu đó trong request sẽ lặng lẽ chui vô database. Object bất biến biến mọi thay đổi thành một phép gán tường minh, nhìn code là thấy.
Kiểm thử value object
Value object là phần code dễ test nhất trong app: không database, không container, không mock. Test quy tắc khởi tạo, từng thao tác, tính bất biến và phép so sánh:
use App\Support\ValueObjects\EmailAddress;
use App\Support\ValueObjects\Money;
it('adds money of the same currency without touching the originals', function () {
$price = Money::vnd(100_000);
$shipping = Money::vnd(25_000);
$total = $price->add($shipping);
expect($total->amount)->toBe(125_000)
->and($price->amount)->toBe(100_000)
->and($total)->not->toBe($price);
});
it('refuses to mix currencies', function () {
Money::vnd(100)->add(new Money(100, 'USD'));
})->throws(InvalidArgumentException::class, 'Cannot combine VND with USD.');
it('cannot be modified after construction', function () {
$money = Money::vnd(100);
$money->amount = 999;
})->throws(Error::class, 'Cannot modify readonly property');
it('compares by value, not identity', function () {
expect(Money::vnd(500)->equals(Money::vnd(500)))->toBeTrue()
->and(Money::vnd(500)->equals(new Money(500, 'USD')))->toBeFalse();
});
it('normalizes and validates emails', function () {
$email = new EmailAddress(' [email protected] ');
expect((string) $email)->toBe('[email protected]')
->and($email->domain())->toBe('example.com');
expect(fn () => new EmailAddress('not-an-email'))->toThrow(InvalidArgumentException::class);
});
Test "không sửa được" nhìn thì tầm thường, nhưng lỡ ai bỏ readonly để "set đỡ một field" thì nó fail trên CI thay vì trên production.
Những lỗi hay gặp
- Readonly là nông.
DateTimereadonly hay property readonly giữ một service mutable thì bên trong vẫn sửa được. XàiDateTimeImmutablevà chỉ lồng object bất biến. - Xài float cho tiền.
0.1 + 0.2không ra0.3. Lưu số nguyên theo đơn vị nhỏ nhất, hoặc xài thư viện decimal nhưbrick/moneynếu cần độ chính xác tùy ý và metadata tiền tệ. - Validate ngoài constructor. Một
isValid()static mà người gọi có thể quên gọi thì không phải là bảo đảm. Throw ngay trong constructor. - So bằng
===. Hai giá trị bằng nhau là hai instance khác nhau, nên===ra false. Xàiequals(). So lỏng==tình cờ so property với object cùng class, nhưng method tường minh thể hiện rõ ý đồ và lo được quy tắc chuẩn hóa. - Quên gán lại với Eloquent.
$order->total->add($fee)mà thiếu$order->total = ...là không làm gì hết, và sẽ không ai thấy cho tới khi có người đối soát tổng tiền. - Biến mọi thứ thành value object. Class
FirstNamekhông có quy tắc gì chỉ là thủ tục rườm rà. Chỉ bọc những khái niệm có validation, định dạng hoặc hành vi.
Khi nào nên và không nên xài value object readonly
Nên xài cho:
- tiền, số lượng và đơn vị đo, nơi đơn vị hay tiền tệ không bao giờ được trộn;
- định danh và thông tin liên lạc: email, số điện thoại, mã số thuế, số tài khoản có checksum;
- khoảng và kỳ: khoảng ngày, khoảng giá, giờ mở cửa, với invariant kiểu "kết thúc sau bắt đầu";
- DTO truyền giữa các tầng: request DTO dạng
readonly classdựng từ form request, để service không lén sửa input sau lưng người gọi.
Bỏ qua, hoặc xài object mutable bình thường, cho:
- entity có danh tính và vòng đời, như Eloquent model. Đơn hàng đổi trạng thái theo thời gian; model sinh ra để làm chuyện đó;
- builder và bộ gom cố ý tích lũy state từng bước;
- vòng lặp nóng qua hàng triệu phần tử nơi mỗi thao tác cấp phát object mới là đo được. Profile trước đã, nhưng ở đó số nguyên trần có khi là lựa chọn đúng.
Cái bug hóa đơn hồi đầu biến mất chỉ với một thay đổi: public readonly DateTimeImmutable $dueDate. modify() giờ trả về ngày mới, bản gốc đứng yên, và email nhắc hết kéo ngày đến hạn đi theo.
Thử thách
Luyện tập ngay điều vừa học. Viết lời giải, mở gợi ý nếu bí.
Đề bài
Viết value object Money bất biến. PHP: final readonly class Money với public int $amount và public string $currency. Python: frozen dataclass Money(amount: int, currency: str). Constructor phải từ chối currency nào không đúng ba chữ in hoa A-Z (PHP: InvalidArgumentException, Python: ValueError). add(other) trả Money mới với tổng amount, và throw cùng loại exception nếu khác tiền tệ. withAmount(amount) (Python: with_amount) trả Money mới cùng currency. Riêng PHP: equals(other) trả true khi cả amount và currency đều bằng (Python có sẵn so sánh từ dataclass). allocate(ratios) chia amount thành danh sách Money, mỗi phần ứng với một tỉ lệ số nguyên: mỗi phần trước tiên nhận floor(amount * ratio / tổng ratios), rồi phần dư được cộng từng đơn vị một cho các phần tính từ phần đầu tiên, để tổng các phần luôn bằng amount gốc. Object gốc không bao giờ được thay đổi.
Code khởi tạo
final readonly class Money
{
public function __construct(
public int $amount,
public string $currency,
) {
// TODO: throw InvalidArgumentException unless $currency is exactly 3 uppercase letters A-Z
}
public function add(Money $other): Money
{
// TODO: same currency required (else InvalidArgumentException); return a NEW Money with the sum
}
public function withAmount(int $amount): Money
{
// TODO: return a NEW Money with the given amount and the same currency
}
public function equals(Money $other): bool
{
// TODO: true when amount and currency are both equal
}
/** @return list<Money> */
public function allocate(array $ratios): array
{
// TODO: split the amount by integer ratios; each part gets intdiv(amount * ratio, sum of ratios)
// TODO: hand out the leftover units one by one to the first parts so the parts sum to amount
}
}
from dataclasses import dataclass
@dataclass(frozen=True)
class Money:
amount: int
currency: str
def __post_init__(self) -> None:
# TODO: raise ValueError unless currency is exactly 3 uppercase letters A-Z
pass
def add(self, other: "Money") -> "Money":
# TODO: same currency required (else ValueError); return a NEW Money with the sum
raise NotImplementedError
def with_amount(self, amount: int) -> "Money":
# TODO: return a NEW Money with the given amount and the same currency
raise NotImplementedError
def allocate(self, ratios: list[int]) -> list["Money"]:
# TODO: split the amount by integer ratios; each part gets amount * ratio // sum(ratios)
# TODO: hand out the leftover units one by one to the first parts so the parts sum to amount
raise NotImplementedError
Lời giải của bạn
Gợi ý
Đã mở hết gợi ýThao tác nào cũng kết thúc bằng việc tạo Money mới; đừng bao giờ gán vô $this->amount hay self.amount.
Regex kiểu ^[A-Z]{3}$ (Python dùng re.fullmatch với [A-Z]{3}) để kiểm tra mã tiền tệ.
Với allocate, tính hết các phần floor trước, rồi chia phần amount trừ tổng của tụi nó bắt đầu từ index 0.
final readonly class Money
{
public function __construct(
public int $amount,
public string $currency,
) {
if (! preg_match('/^[A-Z]{3}$/', $currency)) {
throw new InvalidArgumentException("Invalid currency [{$currency}].");
}
}
public function add(Money $other): Money
{
if ($other->currency !== $this->currency) {
throw new InvalidArgumentException('Currency mismatch.');
}
return new Money($this->amount + $other->amount, $this->currency);
}
public function withAmount(int $amount): Money
{
return new Money($amount, $this->currency);
}
public function equals(Money $other): bool
{
return $this->amount === $other->amount && $this->currency === $other->currency;
}
/** @return list<Money> */
public function allocate(array $ratios): array
{
$total = array_sum($ratios);
$shares = array_map(fn (int $r): int => intdiv($this->amount * $r, $total), $ratios);
$remainder = $this->amount - array_sum($shares);
for ($i = 0; $remainder > 0; $i++, $remainder--) {
$shares[$i % count($shares)]++;
}
return array_map(fn (int $s): Money => new Money($s, $this->currency), $shares);
}
}
import re
from dataclasses import dataclass
@dataclass(frozen=True)
class Money:
amount: int
currency: str
def __post_init__(self) -> None:
if not re.fullmatch(r"[A-Z]{3}", self.currency):
raise ValueError(f"Invalid currency {self.currency!r}")
def add(self, other: "Money") -> "Money":
if other.currency != self.currency:
raise ValueError("Currency mismatch")
return Money(self.amount + other.amount, self.currency)
def with_amount(self, amount: int) -> "Money":
return Money(amount, self.currency)
def allocate(self, ratios: list[int]) -> list["Money"]:
total = sum(ratios)
shares = [self.amount * r // total for r in ratios]
remainder = self.amount - sum(shares)
for i in range(remainder):
shares[i % len(shares)] += 1
return [Money(s, self.currency) for s in shares]

