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

PHP EP 3: Cú pháp PHP và phạm vi của strict_types

PHP EP 3: Cú pháp PHP và phạm vi của strict_types

Hiểu quy tắc thẻ mở PHP, lý do lược bỏ thẻ đóng ở tệp thuần mã nguồn, thực hành khai báo strict_types=1 và phân biệt phạm vi kiểm tra kiểu tại nơi gọi hàm.

strict_types kiểm tra kiểu tại tệp gọi hàm

Rất nhiều lập trình viên khi mới chuyển sang viết PHP hiện đại thường gặp một tình huống gây bất ngờ: họ cẩn thận đặt dòng declare(strict_types=1); ở ngay đầu tệp thư viện math_lib.php và khai báo rõ kiểu tham số function calc_quota_gb(int $users, int $gbPerUser): int. Thế nhưng khi một tệp khác trong dự án gọi calc_quota_gb("12", 5) với tham số đầu tiên là chuỗi "12", hàm vẫn chạy bình thường mà không hề ném ra ngoại lệ TypeError như kỳ vọng. Tới một tình huống khác, chỉ vì để thừa một dấu cách hoặc dòng trống sau thẻ đóng ?> ở cuối tệp cấu hình, toàn bộ chức năng chuyển hướng trang hoặc thiết lập Cookie bằng hàm header() lại thất bại với cảnh báo Cannot modify header information - headers already sent.

Bài học thứ ba trong lộ trình PHP mở đầu Chặng 1 (Nền tảng ngôn ngữ) và kế thừa trực tiếp môi trường Lab đã dựng ở PHP EP 2. Dựa trên tài liệu chính thức về Thẻ PHP (PHP Tags), Chỉ thị declare và Khai báo kiểu (Type Declarations) trên PHP 8.4, chúng ta sẽ mổ xẻ cách bộ phân tích cú pháp xử lý văn bản bên ngoài thẻ PHP, lý do quy chuẩn PSR-12 bắt buộc bỏ thẻ đóng ?> ở tệp thuần PHP, và đặc biệt là quy tắc quyết định chế độ kiểm tra kiểu dựa trên tệp phát sinh lời gọi hàm (caller file).

Hồ sơ kiểm chứng & Môi trường Lab: Ngày kiểm chứng: Các kịch bản kiểm chứng trong bài được thực thi ngày 26/10/2026 bên trong container Docker cô lập mạng (--network none), tệp gốc chỉ đọc (--read-only) kèm vùng tạm /tmp, chạy dưới tài khoản thường phplab (UID 1001) trên Alpine Linux (aarch64) với PHP 8.4.27 (cli) và Zend Engine v4.4.27. Bài thực hành kiểm chứng trực tiếp hành vi nạp tệp qua require giữa hai tệp có và không có strict_types=1; chưa đề cập tới tự động nạp lớp Composer (sẽ học ở EP 14). Giới hạn kiểm chứng: CLI cục bộ, chưa kiểm chứng production.

Quy tắc thẻ PHP, declare và nạp tệp

Khác với nhiều ngôn ngữ chỉ đọc thuần mã lệnh, trình phân tích cú pháp của PHP được thiết kế với khả năng nhúng vào tài liệu văn bản: mọi ký tự nằm ngoài cặp thẻ <?php ... ?> đều được chuyển thẳng ra bộ đệm đầu ra (output buffer) giống như một lệnh echo ngầm định. Chính đặc tính lịch sử này đòi hỏi người viết mã phải nắm vững hợp đồng kỹ thuật của từng cấu trúc cú pháp ở đầu và cuối tệp:

Cấu trúc cú pháp Đầu vào & Đầu ra chính Hành vi thực tế trên PHP 8.4 Điều kiện / Không nên giả định
Thẻ mở chuẩn ` Đầu vào: Mã nguồn PHP. Đầu ra: Bắt đầu chế độ phân tích cú pháp hoặc in biểu thức. Luôn được bật sẵn trên mọi bản dựng PHP 8.4 không phụ thuộc vào cấu hình `short_open_tag`. Không dùng thẻ ngắn `
Thẻ đóng `?>` ở cuối tệp Đầu vào: Ký tự kết thúc khối PHP. Đầu ra: Thoát chế độ phân tích, xuất thẳng văn bản sau đó. Mọi khoảng trắng hoặc ký tự xuống dòng thứ hai sau `?>` sẽ bị đẩy ngay ra luồng xuất chuẩn. Trong tệp chỉ chứa mã PHP (class, hàm, cấu hình), luôn lược bỏ `?>` để tránh lỗi gửi HTTP header sớm.
`declare(strict_types=1);` Đầu vào: Giá trị `1` hoặc `0`. Đầu ra: Bật kiểm tra kiểu nghiêm ngặt cho các lời gọi hàm trong tệp đó. Ném `TypeError` nếu kiểu đối số truyền vào hoặc kiểu trả về không khớp đúng kiểu đã khai báo. Phải là câu lệnh đầu tiên trong tệp; chỉ có hiệu lực trong phạm vi tệp chứa dòng `declare` đó (file-scoped).
`require` / `require_once` Đầu vào: Đường dẫn tệp. Đầu ra: Nạp và thực thi tệp đích, dừng chương trình nếu thiếu tệp. Ném lỗi nghiêm trọng (`Fatal error: Failed opening required`) nếu đường dẫn tệp không tồn tại. Tệp được nạp vào qua `require` không kế thừa trạng thái `strict_types` của tệp gọi nó và ngược lại.

Tệp gọi quyết định chế độ kiểm tra kiểu

Tại sao PHP quy định declare(strict_types=1); hoạt động theo tệp phát sinh lời gọi hàm (caller file) chứ không phải theo tệp định nghĩa hàm (callee file)? Câu trả lời nằm ở khả năng tương thích hệ sinh thái khi nâng cấp dần từng phần dự án. Nếu thư viện bên thứ ba bật strict_types=1 mà áp đặt ngược lên nơi gọi, toàn bộ mã cũ gọi vào thư viện sẽ gãy đổ khi truyền chuỗi số "12" từ $_GET vào tham số int. Ngược lại, kiểm tra tại tệp gọi hàm cho phép từng tệp trong dự án chủ động bật kiểm tra nghiêm ngặt khi đã sẵn sàng.

Trên PHP 8.4, declare(strict_types=1); kiểm soát hai điểm:

  1. Kiểm tra kiểu đối số truyền vào (Parameter Type Check): Quyết định bởi việc tệp thực hiện lời gọi hàm có declare(strict_types=1); hay không. Nếu tệp gọi bật strict_types=1, mọi lời gọi từ tệp đó tới bất kỳ hàm nào đều bị kiểm tra chặt. Ngoại lệ duy nhất được chuyển đổi ngầm là truyền int vào tham số float.
  2. Kiểm tra kiểu trả về (Return Type Check): Quyết định bởi việc tệp chứa định nghĩa hàm đó có khai báo declare(strict_types=1); hay không.
Các bước biên dịch và thực thi một tệp mã nguồn PHP từ lúc đọc cú pháp đến lúc chạy.
Hình 1. Các bước biên dịch và thực thi một tệp mã nguồn PHP từ lúc đọc cú pháp đến lúc chạy. 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.

Sơ đồ Hình 1 minh họa vòng đời từ lúc đọc thẻ mở <?php, ghi nhận cờ declare(strict_types=1), tạo cây AST cho tới khi Zend VM kiểm tra kiểu.

Tạo thư viện hàm có khai báo kiểu

Để kiểm chứng cơ chế phạm vi theo tệp, chúng ta tạo vùng tạm trong /tmp và dựng tệp thư viện math_lib.php có declare(strict_types=1); cùng hàm calc_quota_gb(int $users, int $gbPerUser): int:

lab_dir="$(mktemp -d /tmp/php-ep03-XXXXXX)"
cd "$lab_dir"

cat << 'EOF' > math_lib.php
<?php
declare(strict_types=1);

function calc_quota_gb(int $users, int $gbPerUser): int
{
    return $users * $gbPerUser;
}
EOF

Trong math_lib.php, cả hai tham số $users, $gbPerUser và kiểu trả về đều được ấn định là int, đồng thời tệp thư viện không đặt thẻ đóng ?> ở cuối tệp để tuân thủ quy chuẩn an toàn luồng xuất.

Gọi hàm trong chế độ ép kiểu mặc định

Tiếp theo, chúng ta tạo tệp caller_coercive.php không khai báo strict_types và truyền chuỗi "12" vào tham số đầu tiên:

cat << 'EOF' > caller_coercive.php
<?php
require __DIR__ . '/math_lib.php';

echo "Coercive=" . calc_quota_gb("12", 5) . PHP_EOL;
EOF

php caller_coercive.php

Kết quả in ra Coercive=60 với mã thoát 0. Dù math_lib.php có declare(strict_types=1);, nhưng lời gọi calc_quota_gb("12", 5) xuất phát từ caller_coercive.php (chế độ ép kiểu tự động), nên Zend Engine vẫn chuyển chuỗi "12" thành số nguyên 12.

Bật strict_types, kiểm tra TypeError và dọn dẹp

Bây giờ chúng ta thực hiện kiểm thử nhánh lỗi (Negative Testing): tạo caller_strict.php có declare(strict_types=1); ở đầu tệp rồi gọi calc_quota_gb("12", 5):

cat << 'EOF' > caller_strict.php
<?php
declare(strict_types=1);

require __DIR__ . '/math_lib.php';

echo "Strict=" . calc_quota_gb("12", 5) . PHP_EOL;
EOF

set +e
php caller_strict.php
echo "Mã thoát khi vi phạm strict_types: $?"
set -e

Khi chạy php caller_strict.php, chương trình dừng ngay với mã thoát 255 và in ngoại lệ: Fatal error: Uncaught TypeError: calc_quota_gb(): Argument #1 ($users) must be of type int, string given, called in .../caller_strict.php on line 6 and defined in .../math_lib.php:4. Thông báo chỉ rõ nơi gọi sai kiểu (caller_strict.php) và nơi đặt hợp đồng kiểu (math_lib.php). Muốn bảo vệ một tệp khỏi truyền nhầm kiểu dữ liệu, chính tệp đó phải khai báo declare(strict_types=1);.

Cơ chế áp dụng declare(strict_types=1) dựa trên tệp phát sinh lời gọi hàm (caller).
Hình 2. Cơ chế áp dụng declare(strict_types=1) dựa trên tệp phát sinh lời gọi hàm (caller). 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.

Hình 2 đối chiếu hai chế độ: Coercive tự ép kiểu vô hướng, còn strict_types=1 chặn sai lệch kiểu ngay tại ranh giới gọi hàm. Kết thúc bài Lab, hãy xóa thư mục tạm:

cd /tmp
rm -rf "$lab_dir"
test ! -e "$lab_dir" && echo "Đã dọn sạch thư mục Lab EP 03."

Áp dụng PSR-12 và tổ chức điểm vào ứng dụng

Trong dự án thực tế theo chuẩn PER Coding Style / PSR-12, cần áp dụng ba quy tắc:

  1. Thứ tự đầu tệp chuẩn: Bắt đầu bằng <?php ở dòng 1 (không có UTF-8 BOM), tiếp theo là declare(strict_types=1);, namespace, use rồi tới nội dung lớp/hàm.
  2. Luôn dùng __DIR__ khi nạp tệp: Viết require __DIR__ . '/math_lib.php'; giúp đường dẫn luôn tuyệt đối theo thư mục chứa tệp hiện tại, không phụ thuộc thư mục đứng gọi lệnh (pwd).
  3. Phạm vi của strict_types: Chỉ kiểm soát ranh giới gọi hàm, trả về hàm và thuộc tính có kiểu; không ngăn toán tử + cộng chuỗi số trong biểu thức thường.

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

Hãy tự trả lời 4 câu hỏi dưới đây:

  • Câu hỏi 1: Nếu A.php có declare(strict_types=1); rồi require 'B.php'; (không có declare), lời gọi hàm trong B.php có chịu chế độ nghiêm ngặt không? Đáp án: Không. declare(strict_types=1) chỉ có hiệu lực trên từng tệp đơn lẻ (file-scoped).
  • Câu hỏi 2: Khi bật strict_types=1, truyền int 10 vào tham số float $price có bị lỗi TypeError không? Đáp án: Không. Mở rộng từ int sang float là ngoại lệ duy nhất được phép chuyển đổi tự động trong chế độ nghiêm ngặt.
  • Câu hỏi 3: Tại sao tệp thuần mã PHP không nên viết thẻ đóng ?> ở cuối tệp? Đáp án: Để ngăn rò ký tự trắng ra luồng xuất. Dấu cách hoặc dòng trống sau ?> khiến PHP gửi thân phản hồi sớm, làm hỏng lệnh header() hoặc Cookie sau đó.
  • Câu hỏi 4: Đặt declare(strict_types=1); bên dưới lệnh require có hợp lệ không? Đáp án: Không. Nó bắt buộc phải là câu lệnh đầu tiên trong tệp, nếu đặt sau sẽ báo Fatal error.

Tiêu chí nghiệm thu: Hoàn thành EP 3 khi giải thích rõ vì sao strict_types=1 phụ thuộc tệp gọi hàm, dùng đúng __DIR__ và bỏ thẻ đóng ?> ở tệp thuần PHP. Ở PHP EP 4, chúng ta sẽ khảo sát biến, hằng số có kiểu, kiểu vô hướng và BcMath\Number.

Ôn bài trước: PHP EP 2: Dựng lab PHP và cấu hình php.ini. Học tiếp: PHP EP 4: Biến, hằng số và ép kiểu trong PHP.

Bài liên quan trong bộ PHP

Tìm hiểu thêm