
PHP EP 20: Transaction PDO, khóa ngoại và Repository trong PHP
Quản lý giao dịch cơ sở dữ liệu bằng beginTransaction, commit và rollBack trong PHP PDO, thực hành bật ràng buộc khóa ngoại và đóng gói truy vấn vào Repository.

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.
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.
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 `` vì mặc định bị tắt trong `php.ini` và gây xung đột với thẻ XML ` |
| 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ạ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:
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.declare(strict_types=1); hay không.
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.
Để 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.
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â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);.

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."
Trong dự án thực tế theo chuẩn PER Coding Style / PSR-12, cần áp dụng ba quy tắc:
<?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.__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).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.Hãy tự trả lời 4 câu hỏi dưới đây:
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).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.?> ở 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 đó.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.

Quản lý giao dịch cơ sở dữ liệu bằng beginTransaction, commit và rollBack trong PHP PDO, thực hành bật ràng buộc khóa ngoại và đóng gói truy vấn vào Repository.

Thiết kế cấu trúc thư mục dự án PHP tách biệt nghiệp vụ và giao diện, thực hành viết kịch bản kiểm thử tự động xác minh nhánh lỗi và đọc Stack Trace để gỡ lỗi.

Tổng hợp kiến thức toàn khóa qua dự án ứng dụng quản lý Ticket bằng PHP và PDO: kết hợp Property Hooks, Enum, Transaction, bảo mật biểu mẫu HTTP và bộ kiểm thử.

Phân tích vòng đời HTTP Request/Response trong PHP qua $_GET, $_POST, $_SERVER, thực hành kiểm tra dữ liệu bằng filter_var và mã hóa đầu ra chống tấn công XSS.