
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.

Cấu hình kết nối cơ sở dữ liệu bằng PDO trong PHP, thực hành truy vấn tham số hóa Prepared Statements để ngăn chặn SQL Injection và kiểm soát chế độ ném lỗi.
Khi ứng dụng cần tìm thông tin người dùng hoặc lọc đơn hàng theo tham số từ trình duyệt, một thói quen nguy hiểm là ghép trực tiếp biến chuỗi vào câu lệnh SQL như "SELECT * FROM users WHERE email = '$email'". Nếu kẻ tấn công nhập chuỗi ' OR '1'='1 vào ô email, câu lệnh gửi tới hệ quản trị cơ sở dữ liệu sẽ biến thành SELECT * FROM users WHERE email = '' OR '1'='1'. Biểu thức '1'='1' luôn đúng với mọi dòng, khiến cơ sở dữ liệu trả về toàn bộ hồ sơ người dùng. Không dùng str_replace() làm cơ chế bảo vệ SQL; truy vấn tham số hóa giúp tách giá trị khỏi cấu trúc câu lệnh.
Bài học mở đầu Chặng 5 này hướng dẫn bạn kết nối cơ sở dữ liệu chuẩn mực bằng PDO (PHP Data Objects) kèm các lớp con chuyên biệt theo trình điều khiển (Pdo\Sqlite, Pdo\Mysql mới trong PHP 8.4), cấu hình chế độ ném ngoại lệ ERRMODE_EXCEPTION, tắt mô phỏng truy vấn ATTR_EMULATE_PREPARES => false và sử dụng Prepared Statements để ngăn dữ liệu tham số thay đổi cấu trúc SQL. Bài viết nối tiếp PHP EP 18: Cookie, Session, CSRF và băm mật khẩu trong PHP và chuẩn bị cho PHP EP 20: Transaction PDO, khóa ngoại và Repository trong PHP. Tài liệu tham chiếu gồm PHP Manual: PDO, Prepared statements và PHP RFC: Driver specific PDO subclasses.
Hồ sơ kiểm chứng & Môi trường Lab (Verification Profile):
php-course-qa:8.4 (--network none, --read-only, --tmpfs /tmp:rw,exec,nosuid,size=64m, user phplab UID 1001).PHP 8.4.27 (cli) và SQLite 3.53.4 (pdo_sqlite) trên Alpine Linux 3.21 (aarch64).Pdo\Sqlite của PHP 8.4 trên SQLite cục bộ; không tuyên bố kiểm chứng cân bằng tải cụm MySQL/PostgreSQL từ xa.Bảng dưới đây tổng hợp hợp đồng kỹ thuật của các cấu hình và phương thức quan trọng trong PDO:
| Tùy chọn / Phương thức | Cấu hình chuẩn trên PHP 8.4 | Hành vi thực tế trong PDO | Điều kiện / Không nên giả định |
|---|---|---|---|
PDO::ATTR_ERRMODE |
PDO::ERRMODE_EXCEPTION (Mặc định PHP 8.0+) |
Mọi lỗi cú pháp SQL, mất kết nối hoặc vi phạm ràng buộc UNIQUE đều ném ngoại lệ PDOException. |
Không hiển thị trực tiếp $e->getMessage() của PDOException ra trình duyệt vì làm lộ cấu trúc bảng. |
PDO::ATTR_DEFAULT_FETCH_MODE |
PDO::FETCH_ASSOC |
Trả về mỗi dòng dưới dạng mảng kết hợp chỉ dùng tên cột làm khóa (không nhân đôi với khóa số như FETCH_BOTH). |
Tránh thêm khóa số; mức giảm bộ nhớ phụ thuộc dữ liệu, không cố định 50%. |
PDO::ATTR_EMULATE_PREPARES |
false |
Yêu cầu prepared statements gốc ở driver hỗ trợ tùy chọn này. | Tùy chọn phụ thuộc driver; SQLite dùng prepare gốc. Kiểu kết quả còn phụ thuộc driver và ATTR_STRINGIFY_FETCHES. |
$pdo->prepare() + $stmt->execute() |
Dùng tham số đặt tên :email hoặc dấu hỏi ? |
Biên dịch cây cú pháp SQL trước, sau đó truyền giá trị tham số thuần túy vào vị trí giữ chỗ. | Tham số giữ chỗ chỉ thay thế cho giá trị (value), không thể dùng làm tên bảng, tên cột hay ASC/DESC! |
| Lớp con chuyên biệt (PHP 8.4) | Pdo\Sqlite::connect($dsn), Pdo\Mysql, Pdo\Pgsql |
Cung cấp các phương thức và hằng số riêng của từng hệ quản trị trên lớp con chính thức. | PDO::connect() trong PHP 8.4 tự động trả về thực thể lớp con tương ứng với DSN. |
Trong lab SQLite, prepare() tạo câu lệnh tham số hóa cục bộ. Với kết nối MySQL, cách chuẩn bị và thời điểm trao đổi với máy chủ phụ thuộc driver và chế độ giả lập. Theo PDO::setAttribute, không được suy rộng một thuộc tính cho mọi driver. Điều cần giữ là giá trị được bind vào vị trí dữ liệu, không được nối vào cấu trúc SQL.
Khi bước thứ hai $stmt->execute([':email' => "' OR '1'='1"]) diễn ra, chuỗi "' OR '1'='1" được bind như một giá trị, không trở thành toán tử của mệnh đề WHERE. Hệ quản trị CSDL chỉ đơn thuần đi tìm xem có bản ghi nào mang email đúng bằng chuỗi ' OR '1'='1 hay không và trả về false.
Từ PHP 8.4, đặc tả PHP RFC: Driver specific PDO subclasses bổ sung PDO::connect($dsn, ...) và các lớp con Pdo\Sqlite, Pdo\Mysql, Pdo\Pgsql giúp phân tách rõ các tính năng đặc thù của từng hệ quản trị.

Chúng ta sẽ khởi tạo cơ sở dữ liệu SQLite bằng PDO, tạo bảng users, chèn dữ liệu bằng Prepared Statement và thử tấn công bằng chuỗi ' OR '1'='1.
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-ep19-XXXXXX)"
cd "$WORKDIR"
pwd
Bước 2: Tạo kịch bản pdo_prepared.php thực thi truy vấn tham số hóa và chặn SQLi
Mục đích: Tạo bảng users, thêm 2 tài khoản, truy vấn tài khoản hợp lệ và kiểm tra kết quả khi truyền chuỗi SQL Injection vào tham số :email.
cat <<'EOF' > "$WORKDIR/pdo_prepared.php"
<?php
declare(strict_types=1);
$pdo = new PDO('sqlite:' . __DIR__ . '/app.sqlite', null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
$pdo->exec('CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT NOT NULL UNIQUE, role TEXT NOT NULL)');
$ins = $pdo->prepare('INSERT INTO users (email, role) VALUES (:email, :role)');
$ins->execute([':email' => 'admin@thanhnh.id.vn', ':role' => 'admin']);
$ins->execute([':email' => 'staff@thanhnh.id.vn', ':role' => 'staff']);
$sel = $pdo->prepare('SELECT email, role FROM users WHERE email = :email');
$sel->execute([':email' => 'admin@thanhnh.id.vn']);
$validRow = $sel->fetch();
$sel->execute([':email' => "' OR '1'='1"]);
$injectedRow = $sel->fetch();
echo "Found={$validRow['role']} | Injected=" . ($injectedRow === false ? 'blocked' : 'leaked') . "\n";
EOF
php "$WORKDIR/pdo_prepared.php"
Kết quả quan sát được:
Found=admin | Injected=blocked.$sel đã prepare một lần có thể gọi execute() lặp lại nhiều lần với các bộ tham số khác nhau và chặn đứng hoàn toàn chuỗi tấn công ' OR '1'='1.Cảnh báo an toàn: Phương thức $stmt->fetch() trả về false khi không tìm thấy bản ghi nào. Hãy luôn kiểm tra if ($row === false) trước khi đọc $row['role'].
Khi truyền mảng trực tiếp vào $stmt->execute([':lim' => $lim]), PDO mặc định coi mọi phần tử là kiểu chuỗi (PDO::PARAM_STR). Khi làm việc với LIMIT :lim hoặc cột số nguyên chặt chẽ, việc gọi $stmt->bindValue(':lim', $lim, PDO::PARAM_INT) giúp truyền đúng kiểu số nguyên.
Bước 3: Tạo kịch bản pdo_php84_subclass.php dùng Pdo\Sqlite::connect() và bindValue()
Mục đích: Khởi tạo kết nối qua lớp con Pdo\Sqlite mới của PHP 8.4 và dùng bindValue() với PDO::PARAM_INT.
cat <<'EOF' > "$WORKDIR/pdo_php84_subclass.php"
<?php
declare(strict_types=1);
$db = Pdo\Sqlite::connect('sqlite:' . __DIR__ . '/app.sqlite', null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$stmt = $db->prepare('SELECT email FROM users ORDER BY id ASC LIMIT :lim');
$stmt->bindValue(':lim', 1, PDO::PARAM_INT);
$stmt->execute();
echo "Class=" . $db::class . " | FirstEmail=" . $stmt->fetchColumn() . "\n";
EOF
php "$WORKDIR/pdo_php84_subclass.php"
Kết quả quan sát được:
Class=Pdo\Sqlite | FirstEmail=admin@thanhnh.id.vn. Phương thức $stmt->fetchColumn() đọc trực tiếp giá trị cột đầu tiên.Cảnh báo an toàn: Vì tham số PDO không thể đại diện cho tên cột (ORDER BY :col), khi cho phép người dùng chọn cột sắp xếp, bạn bắt buộc phải đối chiếu qua danh sách trắng (allowlist) bằng biểu thức match ($inputSort).
Hãy kiểm chứng cách PDO::ERRMODE_EXCEPTION phản ứng khi chèn địa chỉ email bị trùng lặp vào cột UNIQUE.
Bước 4: Chèn bản ghi trùng email và kiểm tra ngoại lệ PDOException
Mục đích: Xác nhận PDO ném ngoại lệ PDOException ngay khi vi phạm ràng buộc toàn vẹn.
set +e
php -r '
$pdo = new PDO("sqlite:'"$WORKDIR"'/app.sqlite");
$stmt = $pdo->prepare("INSERT INTO users (email, role) VALUES (:e, :r)");
$stmt->execute([":e" => "admin@thanhnh.id.vn", ":r" => "guest"]);
'
RC=$?
set -e
echo "RC=$RC"
Kết quả quan sát được:
RC=255 kèm thông báo: Fatal error: Uncaught PDOException: SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed: users.email.
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"
Khi kết nối MySQL/MariaDB hoặc PostgreSQL trên Production, hãy tuân thủ 2 quy tắc:
charset=utf8mb4 trong DSN của MySQL: Khai báo mysql:host=...;dbname=...;charset=utf8mb4 để lưu trọn vẹn mọi ký tự Unicode 4 byte và đọc mật khẩu DB từ biến môi trường (getenv('DB_PASSWORD')).WHERE id IN (?, ?, ?) động: Tạo chuỗi dấu hỏi bằng $placeholders = implode(',', array_fill(0, count($ids), '?')); rồi truyền array_values($ids) vào $stmt->execute().Hãy tự kiểm tra lại kiến thức qua 4 câu hỏi tình huống:
' OR '1'='1 vào tham số của Prepared Statement không thể làm thay đổi logic của mệnh đề WHERE? -> Vì tham số được xử lý như giá trị, không phải cú pháp SQL. Giá trị truyền ở execute() chỉ được coi là dữ liệu thuần túy.ORDER BY :column không? -> Không. Tham số giữ chỗ chỉ thay thế cho giá trị dữ liệu; với tên cột sắp xếp phải dùng allowlist qua biểu thức match.PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC có lợi gì so với PDO::FETCH_BOTH? -> Chỉ tạo khóa theo tên cột, tránh thêm khóa số của FETCH_BOTH; không suy ra tỷ lệ tiết kiệm bộ nhớ cố định.$stmt->fetch() không tìm thấy dòng nào khớp điều kiện, giá trị trả về của nó là null hay false? -> Trả về false. Phải kiểm tra if ($row === false) trước khi đọc các trường.Hoàn thành bài học này, bạn đã nắm vững cách truy vấn an toàn với PDO. Hãy tiếp tục sang PHP EP 20: Transaction PDO, khóa ngoại và Repository trong PHP để quản lý giao dịch và đóng gói tầng lưu trữ.
Ôn bài trước: PHP EP 18: Cookie, Session, CSRF và băm mật khẩu trong PHP. Học tiếp: 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.

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.