Sửa lỗi PHP Fatal error: Cannot modify readonly property trong PHP 8.1+

intermediate🐘 PHP2026-07-08| PHP 8.1, PHP 8.2, PHP 8.3+ trên mọi hệ điều hành (Linux, Windows, macOS)

Error Message

Fatal error: Uncaught Error: Cannot modify readonly property ClassName::$propertyName
#php#readonly#oop#php8

Tình huống

Chúng tôi đang trong quá trình chuyển đổi một codebase 10 năm tuổi sang PHP 8.2 thì gặp bế tắc. Để hiện đại hóa ứng dụng, chúng tôi đã triển khai các thuộc tính readonly cho các Data Transfer Objects (DTOs). Điều này hoạt động hoàn hảo cho các request đơn giản, nhưng mọi thứ sụp đổ trong một đợt đồng bộ hóa định kỳ 5.000 bản ghi người dùng.

Ứng dụng bị dừng giữa chừng với một thông báo lỗi cụ thể và gây ức chế:

Fatal error: Uncaught Error: Cannot modify readonly property UserProfile::$username in /var/www/html/src/Models/UserProfile.php:14

Modifier readonly của PHP 8.1 rất nghiêm ngặt. Khi bạn đã gán giá trị, thuộc tính đó sẽ bị khóa. Bạn chỉ có thể khởi tạo nó một lần duy nhất và chỉ từ bên trong class nơi nó được định nghĩa. Bất kỳ thay đổi nào sau đó—ngay cả khi bạn cố gắng gán cùng một giá trị—cũng sẽ kích hoạt Fatal Error và dừng script ngay lập tức.

Quá trình Debug

Lỗi này thường xuất hiện trong các vòng lặp hoặc khi cập nhật trạng thái. Trong trường hợp của chúng tôi, logic đại loại như sau:

class UserProfile {
    public readonly string $username;

    public function setUsername(string $name) {
        $this->username = $name; // Chỉ hoạt động nếu $username chưa được khởi tạo
    }
}

$profile = new UserProfile();
$profile->setUsername('jdoe');
$profile->setUsername('smith'); // FATAL ERROR: Thuộc tính đã bị khóa

Trình debugger chỉ thẳng vào lần gọi setUsername thứ hai. Mặc dù việc gán giá trị diễn ra bên trong một method của class, thuộc tính đã trở thành bất biến (immutable) ngay khi 'jdoe' được gán. Điều này cũng xảy ra nếu bạn cố gắng unset() một thuộc tính readonly hoặc sửa đổi nó từ phạm vi bên ngoài.

Các giải pháp khắc phục lỗi

1. Sử dụng Constructor Promotion

Cách khắc phục sạch sẽ nhất là xử lý việc khởi tạo trong quá trình tạo object. Constructor promotion giúp giảm bớt mã thừa (boilerplate) và đảm bảo thuộc tính được thiết lập đúng một lần khi object được nạp vào bộ nhớ. Đây là cách tiếp cận tiêu chuẩn cho các DTO trong PHP hiện đại.

class UserProfile {
    // Cách viết rút gọn trong PHP 8.1+
    public function __construct(
        public readonly string $username
    ) {}
}

// Việc sử dụng giờ đây đã an toàn và mang tính nguyên tử
$profile = new UserProfile('jdoe');

2. Triển khai "Wither" Pattern

Nếu bạn thực sự cần thay đổi một giá trị thì sao? Trong thế giới của các đối tượng bất biến, bạn không sửa đổi object hiện có. Thay vào đó, bạn trả về một instance hoàn toàn mới với dữ liệu đã được cập nhật. Điều này giúp trạng thái của object gốc luôn ổn định trong suốt ứng dụng 10.000 dòng code của bạn.

class UserProfile {
    public function __construct(
        public readonly string $username,
        public readonly string $email
    ) {}

    public function withUsername(string $newUsername): self {
        // Tạo một bản sao với giá trị mới
        return new self($newUsername, $this->email);
    }
}

$profile = new UserProfile('jdoe', 'jane@example.com');
$updatedProfile = $profile->withUsername('smith');

// $profile gốc vẫn giữ giá trị 'jdoe'

3. Tận dụng tính năng Cloning của PHP 8.2

Đối với những người đang chạy PHP 8.2 trở lên, có một ngoại lệ cụ thể cho quy tắc này. Hiện tại bạn có thể khởi tạo lại các thuộc tính readonly trong khi gọi __clone(). Điều này cực kỳ hữu ích cho các object phức tạp hoặc các báo cáo cần một mốc thời gian mới khi được sao chép.

class Report {
    public function __construct(
        public readonly DateTime $createdAt
    ) {}

    public function __clone() {
        // PHP 8.2 cho phép ghi đè một lần duy nhất này trong quá trình clone
        $this->createdAt = new DateTime(); 
    }
}

4. Đánh giá lại từ khóa Readonly

Đừng làm phức tạp hóa các model của bạn. Nếu một thuộc tính như status hoặc lastLogin thay đổi thường xuyên trong suốt session của người dùng, thì readonly không phải là công cụ phù hợp. Đơn giản là hãy xóa từ khóa đó đi. Sử dụng các thuộc tính private với getter và setter tiêu chuẩn để duy trì quyền kiểm soát mà không cần đến sự bất biến cứng nhắc của readonly.

Xác minh

Tôi xác minh các bản sửa lỗi này bằng cách chạy một script CLI nhỏ trước khi deploy lên production. Danh sách kiểm tra của tôi bao gồm:

- Khởi tạo object và kiểm tra giá trị ban đầu.
- Kích hoạt method "Wither" hoặc clone.
- Sử dụng `var_dump()` để xác nhận object gốc không thay đổi.
- Đảm bảo script kết thúc với exit code là 0, nghĩa là không có Fatal Error nào xảy ra.

Những lưu ý quan trọng

Các thuộc tính Readonly rất mạnh mẽ trong việc giữ cho dữ liệu nhất quán, nhưng chúng đòi hỏi sự thay đổi trong cách bạn quản lý trạng thái. Nếu bạn thấy mình phải đấu tranh với lỗi này hàng ngày, kiến trúc của bạn có thể cần chuyển hướng sang tính bất biến (Wither pattern) hoặc các thuộc tính đóng gói đơn giản hơn. Hãy ưu tiên sử dụng constructor promotion bất cứ khi nào có thể để tránh việc vô tình gán giá trị hai lần.

Related Error Notes