Chuyển đến nội dung chính (Skip to content)
Mục lục bài viết
PHP

PHP EP 12: Property Hooks và Asymmetric Visibility trong PHP 8.4

PHP EP 12: Property Hooks và Asymmetric Visibility trong PHP 8.4

Làm chủ Property Hooks (get/set) và Asymmetric Visibility (public private(set)) trong PHP, thực hành kiểm tra dữ liệu gán vào thuộc tính và phân biệt với __get.

Kiểm soát việc đọc và ghi thuộc tính PHP

Trong các lớp thực thể nghiệp vụ (Domain Entities) như UserAccount hay Invoice, chúng ta thường có hai nhu cầu thiết thực đối với thuộc tính: thứ nhất là tự động chuẩn hóa hoặc tính toán giá trị mỗi khi đọc/ghi (như chuẩn hóa email về chữ thường đã cắt khoảng trắng, hoặc trích xuất tên miền từ email); thứ hai là cho phép bên ngoài đọc trực tiếp thuộc tính (như số lần đăng nhập sai $user->loginAttempts) nhưng chỉ cho phép các phương thức nội bộ trong lớp thay đổi nó. Trước PHP 8.4, lập trình viên buộc phải biến mọi thuộc tính thành private rồi viết hàng loạt hàm getEmail(), setEmail(), getDomain() lặp đi lặp lại, hoặc dùng __get()/__set() làm mất khả năng kiểm tra kiểu tĩnh.

Bài học này giúp bạn làm chủ hai tính năng hướng đối tượng quan trọng bậc nhất của PHP 8.4: Property Hooks (get / set) và Asymmetric Visibility (public private(set), public protected(set)). Bạn sẽ phân biệt được thuộc tính có lưu trữ (backed property) với thuộc tính ảo (virtual property) và bảo vệ trạng thái đối tượng gọn gàng. Bài viết kế thừa kiến thức từ PHP EP 11: Class, Object và Constructor Promotion trong PHP và chuẩn bị cho PHP EP 13: Interface, Trait và Enum trong PHP. Tài liệu tham chiếu gồm PHP RFC: Property Hooks, PHP RFC: Asymmetric Visibility v2 và PHP Manual: Property Hooks.

Hồ sơ kiểm chứng & Môi trường Lab (Verification Profile):

  • Ngày kiểm chứng: 2026-10-26.
  • Môi trường thực thi: Container cô lập php-course-qa:8.4 (--network none, --read-only, --tmpfs /tmp:rw,exec,nosuid,size=64m, user phplab UID 1001).
  • Phiên bản runtime: PHP 8.4.27 (cli) trên Alpine Linux 3.21 (aarch64), Zend Engine v4.4.27.
  • Giới hạn kiểm chứng: Kiểm chứng hành vi runtime của Property Hooks và Asymmetric Visibility trên lớp độc lập trong PHP 8.4; không tuyên bố kiểm chứng tương thích với các thư viện tạo proxy bằng Reflection cũ chưa nâng cấp lên chuẩn PHP 8.4.

Phân biệt Backed Hook, Virtual Hook và quyền ghi

Mỗi cơ chế can thiệp vào thuộc tính trong PHP 8.4 có cách cấp phát bộ nhớ và giới hạn thao tác khác nhau. Bảng dưới đây tổng hợp hợp đồng kỹ thuật chi tiết:

Cơ chế PHP 8.4 Cú pháp khai báo Cấp phát bộ nhớ & Hành vi thực tế Điều kiện / Không nên giả định
Backed Property Hook (Thuộc tính có lưu trữ) public string $email { set => strtolower(trim($value)); } Có ô nhớ lưu trữ thực (`backing store`) trong đối tượng; ít nhất một nhánh get/set tham chiếu tới $this->email. Với biểu thức rút gọn set => expr, kết quả của biểu thức tự động được gán vào ô nhớ thuộc tính.
Virtual Property Hook (Thuộc tính ảo) public string $domain { get => substr(strrchr($this->email, '@'), 1); } Không tốn ô nhớ lưu trữ trên đối tượng; giá trị được tính toán động mỗi lần đọc vì không nhánh nào dùng $this->domain. Nếu chỉ khai báo nhánh get cho thuộc tính ảo, mọi nỗ lực gán giá trị vào thuộc tính đó sẽ ném lỗi Error.
Asymmetric Visibility (Phạm vi bất đối xứng) public private(set) int $loginAttempts = 0; Cho phép đọc ở phạm vi public nhưng chỉ cho phép gán/thay đổi ở phạm vi private (bên trong chính lớp đó). Phạm vi set bắt buộc hẹp hơn hoặc bằng phạm vi get (không thể khai báo private public(set)) và phải có kiểu tường minh.
Tương tác với Tham chiếu (&$obj->prop) Lấy tham chiếu tới thuộc tính có set hook hoặc private(set) Bị chặn từ bên ngoài để ngăn việc lách qua set hook hoặc sửa lén thuộc tính private(set). Không thể sửa mảng trực tiếp bằng $obj->items[] = $x từ bên ngoài nếu $items có set hook hoặc private(set).

Property Hooks và quyền truy cập hoạt động ra sao

Theo PHP RFC: Property Hooks, Zend Engine cho phép gắn trực tiếp hai nhánh get và set vào khai báo thuộc tính. Trình biên dịch tự động phân biệt giữa Backed Property và Virtual Property:

  • Nếu bên trong thân get hoặc set có truy cập $this->propertyName (hoặc dùng cú pháp rút gọn set => ...), Zend Engine cấp phát một ô nhớ thực (backing value) cho thuộc tính. Trong nhánh set, biến mặc định $value chứa giá trị đang truyền vào (đã kiểm tra khớp kiểu).
  • Ngược lại, nếu cả nhánh get và set đều không nhắc tới $this->propertyName (như public string $domain { get => ... }), đó là Virtual Property không chiếm bộ nhớ lưu trữ và không xuất hiện khi var_dump().

Song hành cùng Property Hooks, PHP RFC: Asymmetric Visibility v2 giải quyết bài toán "đọc công khai, ghi nội bộ" mà readonly không đáp ứng được (do readonly cấm cả chính lớp đó cập nhật lần hai). Với public private(set) int $loginAttempts = 0;, bên ngoài đọc được $user->loginAttempts trực tiếp, nhưng chỉ các phương thức bên trong lớp mới được phép thực hiện $this->loginAttempts++.

Luồng đánh chặn thao tác đọc (get) và ghi (set) trực tiếp trên thuộc tính của đối tượng.
Hình 1. Luồng đánh chặn thao tác đọc (get) và ghi (set) trực tiếp trên thuộc tính của đối tượng. Sơ đồ minh họa khái niệm; không phải ảnh chụp hay bản ghi kiểm thử. Mở hình để xem chi tiết.

Tạo UserAccount có hook và quyền ghi riêng

Chúng ta sẽ xây dựng lớp UserAccount kết hợp cả ba kỹ thuật của PHP 8.4: Backed Property Hook chuẩn hóa email khi gán, Virtual Property Hook trích xuất tên miền, và public private(set) bảo vệ bộ đếm đăng nhập sai.

Bước 1: Tạo vùng làm việc tạm an toàn

Mục đích: Khởi tạo thư mục thực hành riêng trong /tmp.

WORKDIR="$(mktemp -d /tmp/php-ep12-XXXXXX)"
cd "$WORKDIR"
pwd

Bước 2: Tạo kịch bản user_account.php kiểm chứng Property Hooks và private(set)

Mục đích: Xác nhận nhánh set tự động làm sạch chuỗi email, nhánh get ảo tính tên miền chính xác và phương thức nội bộ tăng được biến đếm private(set).

cat <<'EOF' > "$WORKDIR/user_account.php"
<?php
declare(strict_types=1);

class UserAccount
{
    public string $email {
        set(string $value) {
            $clean = strtolower(trim($value));
            if (!str_contains($clean, '@')) {
                throw new InvalidArgumentException('Invalid email');
            }
            $this->email = $clean;
        }
    }

    public string $domain {
        get => substr((string) strrchr($this->email, '@'), 1);
    }

    public private(set) int $loginAttempts = 0;

    public function __construct(string $email)
    {
        $this->email = $email;
    }

    public function recordFailedLogin(): void
    {
        $this->loginAttempts++;
    }
}

$u = new UserAccount('  Admin@ThanhNH.id.vn ');
$u->recordFailedLogin();

echo "Email={$u->email} | Domain={$u->domain} | Attempts={$u->loginAttempts}\n";
EOF
php "$WORKDIR/user_account.php"

Kết quả quan sát được:

  • Màn hình hiển thị: Email=admin@thanhnh.id.vn | Domain=thanhnh.id.vn | Attempts=1.
  • Khi new UserAccount(...) gán $this->email = $email, giá trị đi qua nhánh set, cắt khoảng trắng và đổi về chữ thường admin@thanhnh.id.vn.
  • Khi đọc $u->domain, nhánh get ảo tính toán tức thời trả về thanhnh.id.vn.

Cảnh báo an toàn: Không đặt các tác vụ tốn tài nguyên (như truy vấn SQL hay gọi HTTP API) bên trong nhánh get của Property Hook, vì người đọc mã luôn kỳ vọng truy cập thuộc tính $u->domain là thao tác bộ nhớ tức thời.

Kết hợp set hook với Constructor Promotion

Khi chỉ cần biến đổi giá trị bằng một biểu thức đơn giản, PHP 8.4 cho phép viết gọn set => biểu_thức ngay trong danh sách tham số của __construct().

Bước 3: Tạo kịch bản compact_hooks.php sử dụng biểu thức set => rút gọn

Mục đích: Viết lớp ProductSku dùng Constructor Property Promotion kết hợp public protected(set) và biểu thức set => tự động in hoa mã sản phẩm.

cat <<'EOF' > "$WORKDIR/compact_hooks.php"
<?php
declare(strict_types=1);

class ProductSku
{
    public function __construct(
        public string $code {
            set => strtoupper(trim($value));
        },
        public protected(set) int $stock = 0,
    ) {}

    public function restock(int $added): void
    {
        if ($added <= 0) {
            throw new InvalidArgumentException('Added stock must be positive.');
        }
        $this->stock += $added;
    }
}

$sku = new ProductSku('  vn-srv-01 ', 15);
$sku->restock(5);
echo "SKU={$sku->code} | Stock={$sku->stock}\n";
EOF
php "$WORKDIR/compact_hooks.php"

Kết quả quan sát được:

  • Đầu ra hiển thị: SKU=VN-SRV-01 | Stock=20. Biến $value trong set => strtoupper(trim($value)) tự động nhận đối số truyền vào và ghi kết quả vào ô nhớ của $this->code.

Cảnh báo an toàn: Khi đọc $this->prop trực tiếp bên trong thân get của chính $prop, PHP 8.4 đọc ô nhớ lưu trữ gốc (backing store), nhưng nếu bạn gọi qua một phương thức khác quay lại đọc $this->prop thì có thể gây đệ quy vô hạn.

Kiểm tra lỗi ghi vào private(set) và dọn dẹp

Chúng ta sẽ thử gán trực tiếp $u->loginAttempts = 0 từ phạm vi toàn cục bên ngoài lớp UserAccount để kiểm chứng cơ chế bảo vệ của public private(set).

Bước 4: Thực thi phép gán trái phép vào thuộc tính private(set) và kiểm tra mã thoát

Mục đích: Xác nhận Zend Engine chặn đứng hành vi sửa đổi thuộc tính private(set) từ bên ngoài lớp bằng lỗi Error.

set +e
php -r '
require "'"$WORKDIR"'/user_account.php";
$u->loginAttempts = 0;
'
RC=$?
set -e
echo "RC=$RC"

Kết quả quan sát được:

  • Câu lệnh $u->loginAttempts = 0; ở phạm vi toàn cục làm tiến trình dừng ngay lập tức với RC=255 kèm lỗi: Fatal error: Uncaught Error: Cannot modify private(set) property UserAccount::$loginAttempts from global scope.
So sánh thuộc tính có vùng lưu trữ (Backed Property), thuộc tính ảo và private(set).
Hình 2. So sánh thuộc tính có vùng lưu trữ (Backed Property), thuộc tính ảo và private(set). Sơ đồ minh họa khái niệm; không phải ảnh chụp hay bản ghi kiểm thử. Mở hình để xem chi tiết.

Bước 5: Dọn dẹp vùng làm việc tạm

Mục đích: Xóa thư mục thực hành trong /tmp.

rm -rf "$WORKDIR"

Giới hạn của hook với Interface và Serialization

Khi đưa Property Hooks và Asymmetric Visibility vào dự án thực tế, cần nắm vững hai quy tắc:

  1. Hợp đồng thuộc tính trên interface (PHP 8.4): Giờ đây interface có thể khai báo trực tiếp interface Identifiable { public string $id { get; } }. Lớp thực thi có thể thỏa mãn { get; } bằng thuộc tính public thường, readonly, public private(set) hoặc thuộc tính ảo có nhánh get.
  2. Tương thích với tuần tự hóa (Serialization): Thuộc tính ảo (virtual property) không có ô nhớ lưu trữ nên không xuất hiện trong kết quả của serialize() hay var_export().

Tự kiểm tra và giới hạn

Hãy tự kiểm tra lại kiến thức qua 4 câu hỏi tình huống:

  • Câu hỏi 1: Làm sao trình biên dịch PHP 8.4 phân biệt Backed Property và Virtual Property khi dùng Property Hook? -> Dựa vào việc thân hook có truy cập $this->tenThuocTinh hay không. Nếu có đọc/ghi $this->tenThuocTinh (hoặc dùng set => expr), đó là Backed Property; nếu không nhắc tới, đó là Virtual Property.
  • Câu hỏi 2: Khai báo private public(set) string $name; có hợp lệ trong PHP 8.4 không? -> Không hợp lệ (Fatal error). Quyền set bắt buộc phải bằng hoặc hẹp hơn quyền get (public -> protected -> private).
  • Câu hỏi 3: Sự khác biệt cốt lõi giữa public readonly int $count và public private(set) int $count là gì? -> Khả năng cập nhật nhiều lần từ bên trong lớp. readonly chỉ cho gán đúng 1 lần duy nhất, còn public private(set) khóa việc ghi từ bên ngoài nhưng cho phép các phương thức nội bộ cập nhật giá trị nhiều lần.
  • Câu hỏi 4: Có thể gắn Property Hook lên thuộc tính tĩnh (static) hoặc thuộc tính readonly không? -> Không. Property Hooks chỉ áp dụng cho thuộc tính thực thể (instance properties) và không kết hợp trực tiếp với readonly.

Hoàn thành bài học này, bạn đã nắm vững cách đóng gói trạng thái đối tượng hiện đại nhất của PHP 8.4. Hãy tiếp tục sang PHP EP 13: Interface, Trait và Enum trong PHP để thiết kế hợp đồng giao diện và kiểu Enum an toàn.

Ôn bài trước: PHP EP 11: Class, Object và Constructor Promotion trong PHP. Học tiếp: PHP EP 13: Interface, Trait và Enum trong PHP.

Bài liên quan trong bộ PHP

Tìm hiểu thêm