
PHP EP 21: Tổ chức dự án và kiểm thử tự động PHP
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.

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.
Trong các nghiệp vụ thực tế như tạo đơn hàng kèm chi tiết mặt hàng, chuyển khoản giữa hai ví hoặc khởi tạo phòng ban kèm phiếu hỗ trợ đầu tiên, một hành động của người dùng đòi hỏi phải ghi xuống 2 đến 3 bảng cơ sở dữ liệu liên tiếp. Ở chế độ mặc định (autocommit), mỗi câu lệnh $stmt->execute() được chốt cứng xuống đĩa ngay lập tức. Nếu câu lệnh thứ nhất ghi thành công nhưng câu lệnh thứ hai gặp lỗi vi phạm khóa ngoại hoặc mất kết nối, cơ sở dữ liệu sẽ rơi vào trạng thái "nửa vời": tiền đã trừ ở ví A nhưng chưa cộng vào ví B, hoặc bản ghi cha đã tạo nhưng không có bản ghi con đi kèm.
Bài học này hướng dẫn bạn quản lý giao dịch cơ sở dữ liệu (Database Transactions) theo nguyên tắc nguyên tử (Atomicity: thành công toàn bộ hoặc hủy bỏ toàn bộ) với beginTransaction(), commit() và rollBack() trong PHP PDO, kích hoạt kiểm tra ràng buộc khóa ngoại (FOREIGN KEY), và đóng gói toàn bộ logic SQL vào lớp Repository tách biệt hoàn toàn với thực thể miền (Domain Entity). Bài viết nối tiếp PHP EP 19: PDO và truy vấn tham số hóa trong PHP và chuẩn bị cho PHP EP 21: Tổ chức dự án và kiểm thử tự động PHP. Tài liệu tham chiếu gồm PHP Manual: Transactions and auto-commit và SQLite Foreign Key Support.
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).rollBack(), ràng buộc khóa ngoại trên SQLite và mẫu thiết kế Repository; không tuyên bố kiểm chứng giao dịch phân tán hai pha (2PC / XA) giữa nhiều máy chủ CSDL.Bảng dưới đây tổng hợp hợp đồng kỹ thuật của các phương thức điều khiển giao dịch PDO và kiến trúc Repository:
| Phương thức / Thành phần | Cú pháp chuẩn | Hành vi thực tế trong PHP 8.4 & PDO | Điều kiện / Không nên giả định |
|---|---|---|---|
$pdo->beginTransaction() |
Gọi ngay trước chuỗi câu lệnh ghi dữ liệu liên kết | Tắt chế độ tự động chốt (`autocommit`); mọi thay đổi chỉ nằm trong vùng đệm giao dịch cho tới khi gọi commit() hoặc rollBack(). |
PDO không hỗ trợ lồng trực tiếp hai lệnh beginTransaction() liên tiếp (sẽ ném PDOException: There is already an active transaction). |
$pdo->commit() |
Gọi ở dòng cuối của khối try khi mọi câu lệnh thành công |
Ghi nhận vĩnh viễn toàn bộ thay đổi trong giao dịch xuống cơ sở dữ liệu và trả kết nối về chế độ autocommit. |
Trong MySQL/MariaDB, các câu lệnh DDL (như CREATE TABLE, ALTER TABLE) sẽ gây **tự động commit ngầm (implicit commit)**! |
$pdo->inTransaction() + $pdo->rollBack() |
if ($pdo->inTransaction()) { $pdo->rollBack(); } trong khối catch |
Hủy bỏ sạch sẽ toàn bộ các thao tác INSERT/UPDATE/DELETE đã chạy từ lúc mở beginTransaction(). |
Luôn kiểm tra inTransaction() trước khi gọi rollBack() để tránh lỗi nếu giao dịch đã bị đóng tự động. |
| Ràng buộc Khóa ngoại trên SQLite | $pdo->exec('PRAGMA foreign_keys = ON'); |
Bắt buộc SQLite kiểm tra sự tồn tại của khóa chính ở bảng cha khi chèn/sửa bảng con hoặc xóa bảng cha. | Mặc định SQLite tắt kiểm tra khóa ngoại trên mỗi kết nối mới; **bắt buộc phải chạy lệnh PRAGMA này ngay sau khi mở kết nối**. |
Lớp Repository |
final readonly class TicketRepository |
Đóng gói đối tượng PDO, chuyển đổi giữa bản ghi bảng SQL và đối tượng thực thể miền (`Entity`). |
Không trả về đối tượng PDOStatement thô ra ngoài Repository; luôn trả về thực thể miền có kiểu. |
Một giao dịch (Transaction) đảm bảo 4 tính chất ACID (Atomicity, Consistency, Isolation, Durability). Khi bọc chuỗi thao tác ghi vào khuôn mẫu chuẩn (beginTransaction() -> try -> commit() -> catch (\Throwable $e) -> rollBack()), có 3 quy tắc sống còn cần ghi nhớ:
\Throwable $e (thay vì chỉ PDOException) để nếu giữa hai câu lệnh SQL có một bước tính toán bằng PHP bị lỗi TypeError hoặc InvalidArgumentException, giao dịch vẫn được hoàn tác sạch sẽ.if ($pdo->inTransaction()) trước khi gọi $pdo->rollBack(), tránh việc ném chồng thêm ngoại lệ There is no active transaction nếu giao dịch đã bị đóng ngầm trước đó.rollBack(), phải ném lại ngoại lệ throw $e; để tầng dịch vụ bên trên biết tác vụ đã thất bại.
Chúng ta sẽ tạo cơ sở dữ liệu SQLite có bật PRAGMA foreign_keys = ON với hai bảng departments và tickets, thực hiện một giao dịch hợp lệ (commit) và một giao dịch cố tình chèn dept_id = 999 không tồn tại để chứng minh lệnh chèn phòng ban trước đó được rollBack() sạch sẽ.
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-ep20-XXXXXX)"
cd "$WORKDIR"
pwd
Bước 2: Tạo kịch bản pdo_tx.php kiểm chứng Commit và Rollback khi lỗi khóa ngoại
Mục đích: Xác minh giao dịch thứ hai khi thất bại ở bước chèn tickets sẽ hoàn tác luôn cả lệnh chèn phòng ban 'Billing' đã chạy ngay trước đó.
cat <<'EOF' > "$WORKDIR/pdo_tx.php"
<?php
declare(strict_types=1);
$pdo = new PDO('sqlite:' . __DIR__ . '/tx.sqlite', null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec('CREATE TABLE departments (id INTEGER PRIMARY KEY, name TEXT NOT NULL)');
$pdo->exec('CREATE TABLE tickets (id INTEGER PRIMARY KEY, dept_id INTEGER NOT NULL, title TEXT NOT NULL, FOREIGN KEY(dept_id) REFERENCES departments(id))');
$pdo->beginTransaction();
$pdo->prepare('INSERT INTO departments (id, name) VALUES (1, :name)')->execute([':name' => 'HelpDesk']);
$pdo->prepare('INSERT INTO tickets (dept_id, title) VALUES (1, :title)')->execute([':title' => 'VPN Error']);
$pdo->commit();
$rolledBack = false;
try {
$pdo->beginTransaction();
$pdo->prepare('INSERT INTO departments (id, name) VALUES (2, :name)')->execute([':name' => 'Billing']);
$pdo->prepare('INSERT INTO tickets (dept_id, title) VALUES (999, :title)')->execute([':title' => 'Invalid FK']);
$pdo->commit();
} catch (PDOException $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
$rolledBack = true;
}
}
$deptCount = (int) $pdo->query('SELECT COUNT(*) FROM departments')->fetchColumn();
echo "Depts={$deptCount} | RolledBack=" . ($rolledBack ? 'true' : 'false') . "\n";
EOF
php "$WORKDIR/pdo_tx.php"
Kết quả quan sát được:
Depts=1 | RolledBack=true.'Billing' (id = 2) đã chạy xong, nhưng vì câu lệnh kế tiếp chèn dept_id = 999 vi phạm FOREIGN KEY, khối catch đã gọi $pdo->rollBack(). Kết quả là phòng ban 'Billing' bị hủy bỏ hoàn toàn, tổng số phòng ban vẫn giữ nguyên là 1.Cảnh báo an toàn: Nếu quên dòng $pdo->exec('PRAGMA foreign_keys = ON') khi dùng SQLite, câu lệnh chèn dept_id = 999 sẽ chạy thành công mà không báo lỗi, tạo ra bản ghi mồ côi!
Để tầng nghiệp vụ không bị trộn lẫn với câu lệnh SQL, chúng ta áp dụng Repository Pattern: định nghĩa thực thể Department thuần PHP và lớp DepartmentRepository chịu trách nhiệm chuyển đổi giữa dòng dữ liệu SQL và đối tượng Department.
Bước 3: Tạo kịch bản repository_demo.php tách biệt Entity và Repository
Mục đích: Đóng gói thao tác tìm kiếm phòng ban vào lớp DepartmentRepository trả về thực thể Department có kiểu chặt chẽ.
cat <<'EOF' > "$WORKDIR/repository_demo.php"
<?php
declare(strict_types=1);
readonly class Department
{
public function __construct(
public ?int $id,
public string $name,
) {}
}
final readonly class DepartmentRepository
{
public function __construct(private PDO $pdo) {}
public function findById(int $id): ?Department
{
$stmt = $this->pdo->prepare('SELECT id, name FROM departments WHERE id = :id');
$stmt->execute([':id' => $id]);
$row = $stmt->fetch();
if ($row === false) {
return null;
}
return new Department((int) $row['id'], (string) $row['name']);
}
}
$pdo = new PDO('sqlite:' . __DIR__ . '/tx.sqlite', null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$repo = new DepartmentRepository($pdo);
$dept = $repo->findById(1);
$missing = $repo->findById(99);
echo "Dept1=" . ($dept ? $dept->name : 'none') . " | Dept99=" . ($missing === null ? 'null' : 'found') . "\n";
EOF
php "$WORKDIR/repository_demo.php"
Kết quả quan sát được:
Dept1=HelpDesk | Dept99=null. Phương thức findById(int $id): ?Department trả về đúng kiểu đối tượng Department hoặc null.Cảnh báo an toàn: Tuyệt đối không thực hiện các cuộc gọi HTTP API ra bên ngoài hay gửi email ở bên trong cặp beginTransaction() ... commit(), vì thời gian chờ mạng sẽ làm treo khóa bảng trong DB.
Khi phương thức A đã mở $pdo->beginTransaction() rồi gọi phương thức B cũng gọi $pdo->beginTransaction(), PDO sẽ ném lỗi vì không hỗ trợ giao dịch lồng nhau tự động.
Bước 4: Gọi beginTransaction() hai lần liên tiếp và kiểm tra mã thoát
Mục đích: Xác nhận PDO ném ngoại lệ PDOException khi mở chồng giao dịch.
set +e
php -r '
$pdo = new PDO("sqlite::memory:");
$pdo->beginTransaction();
$pdo->beginTransaction();
'
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: There is already an active transaction.
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 xây dựng hệ thống tài chính có độ tranh chấp cao trên Production, cần nắm hai kỹ thuật nâng cao:
SAVEPOINT: Kiểm tra if ($pdo->inTransaction()) để phát lệnh SQL SAVEPOINT sp_1 (và ROLLBACK TO SAVEPOINT sp_1 khi lỗi) thay vì gọi beginTransaction() lần hai.SELECT ... FOR UPDATE: Khi kiểm tra số dư ví rồi mới trừ tiền trên MySQL/PostgreSQL, dùng SELECT balance FROM wallets WHERE id = :id FOR UPDATE bên trong Transaction để khóa dòng đó lại cho tới khi commit().Hãy tự kiểm tra lại kiến thức qua 4 câu hỏi tình huống:
$pdo->beginTransaction() và chạy 2 lệnh INSERT, nhưng kịch bản PHP kết thúc mà quên gọi $pdo->commit()? -> PDO tự động thực hiện rollBack() ngầm khi kết nối đóng lại, hủy bỏ toàn bộ 2 lệnh INSERT đó.FOREIGN KEY không bắt lỗi khi chèn khóa ngoại sai? -> Vì SQLite mặc định tắt kiểm tra khóa ngoại. Bắt buộc phải chạy $pdo->exec('PRAGMA foreign_keys = ON') ngay sau khi mở kết nối.beginTransaction() ... commit()? -> Vì cuộc gọi mạng kéo dài thời gian giữ khóa (lock) trong CSDL, gây tắc nghẽn cho các kết nối khác.Repository trả về Entity là gì? -> Tách biệt tầng nghiệp vụ khỏi cú pháp SQL và đảm bảo an toàn kiểu tĩnh (Type Safety) trên toàn ứng dụng.Bạn đã nắm vững cách quản lý giao dịch và thiết kế lớp Repository. Hãy tiếp tục sang PHP EP 21: Tổ chức dự án và kiểm thử tự động PHP để viết kịch bản kiểm thử tự động cho dự án.
Ôn bài trước: PHP EP 19: PDO và truy vấn tham số hóa trong PHP. Học tiếp: PHP EP 21: Tổ chức dự án và kiểm thử tự động PHP.

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.

Thiết lập Cookie và Session an toàn trong PHP với HttpOnly, SameSite, thực hành cấp mới session ID, kiểm tra CSRF token và băm mật khẩu bằng password_hash.