
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.

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ử.
Khi đã học qua từng chủ đề riêng lẻ từ cú pháp cơ bản, xử lý chuỗi tiếng Việt, mảng, lập trình hướng đối tượng cho tới PDO và bảo mật HTTP, thách thức lớn nhất là phối hợp tất cả các cơ chế đó vào một ứng dụng thống nhất. Trong thực tế, nhiều dự án nhỏ tuy có dùng Class và PDO nhưng lại quên bật declare(strict_types=1), dùng trim() thường làm sót khoảng trắng Unicode tiếng Việt, mở public cho thuộc tính trạng thái khiến bên ngoài sửa lén, gọi INSERT ngoài Transaction, hoặc quên thoát mã htmlspecialchars() khi hiển thị ra giao diện.
Bài học tổng kết khóa học (PHP EP 22) dẫn dắt bạn xây dựng Ứng dụng Quản lý Phiếu hỗ trợ kỹ thuật (Ticket Management System) trên PHP 8.4 và PDO SQLite. Dự án mẫu kết tinh các kỹ thuật đã học xuyên suốt 21 bài trước: cấu trúc PSR-4 với Composer, Backed Enum (TicketStatus), Property Hooks (set chuẩn hóa tiêu đề bằng mb_trim/mb_strlen), Asymmetric Visibility (public private(set)), TicketRepository dùng Prepared Statements + Transaction, hàm mảng array_find() của PHP 8.4, mã hóa chống XSS và bộ kiểm thử tự động. Bài viết tổng hợp từ PHP EP 12, PHP EP 20 và PHP EP 21. Tài liệu tham chiếu gồm PHP 8.4 Release Documentation, PHP Manual: PDO và PHP-FIG PSR-4.
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), Composer 2.10.3 và SQLite 3.53.4 (pdo_sqlite) trên Alpine Linux 3.21 (aarch64).Mỗi tệp mã nguồn trong dự án tổng hợp được thiết kế để giải quyết một bài toán kỹ thuật cụ thể với sự hỗ trợ của PHP 8.4:
| Tầng kiến trúc | Tệp mã nguồn PSR-4 | Kỹ thuật PHP 8.4 áp dụng | Điều kiện / Không nên giả định |
|---|---|---|---|
| Trạng thái miền (Enum) | src/Domain/TicketStatus.php |
Backed Enum (Open = 'open', Resolved = 'resolved') kèm phương thức ánh xạ TicketStatus::from(). |
Ngăn chặn tuyệt đối việc lưu hoặc truyền chuỗi trạng thái tùy tiện ngoài danh mục cho phép. |
| Thực thể miền (Entity) | src/Domain/Ticket.php |
Property Hook set làm sạch tiêu đề bằng mb_trim()/mb_strlen() + public private(set) khóa thuộc tính $status. |
Mọi thao tác khởi tạo hoặc đổi tiêu đề Ticket dưới 3 ký tự đều bị chặn ngay tại thực thể bằng InvalidArgumentException. |
| Kho lưu trữ (Repository) | src/Repository/TicketRepository.php |
final readonly class bọc PDO, thực thi beginTransaction(), prepare(), commit()/rollBack(). |
Tách biệt hoàn toàn cú pháp SQL khỏi lớp Ticket; luôn trả về thực thể Ticket có kiểu rõ ràng. |
| Giao diện & Kiểm thử | tests/app_test.php |
Dùng array_find() (PHP 8.4) lọc nhanh danh sách và htmlspecialchars() chống XSS tại biên hiển thị. |
Dữ liệu lưu trong SQLite giữ nguyên văn bản gốc; chỉ thoát ký tự HTML ngay tại thời điểm xuất ra giao diện. |
Hãy quan sát cách các lớp bảo vệ đan cài vào nhau trong vòng đời của một phiếu hỗ trợ Ticket:
new Ticket(null, " \u{2003}Lỗi kết nối VPN <script> "), lệnh gán $this->title = $title kích hoạt Property Hook set(string $value) của PHP 8.4. Hàm mb_trim($value) xóa sạch khoảng trắng ASCII và Unicode U+2003, kiểm tra mb_strlen($clean, 'UTF-8') >= 3 rồi mới ghi vào ô nhớ.public private(set) TicketStatus $status: bên ngoài đọc được $ticket->status->value, nhưng chỉ phương thức $ticket->resolve() bên trong lớp mới được chuyển trạng thái sang TicketStatus::Resolved.$repo->save($ticket), TicketRepository mở beginTransaction(), dùng Prepared Statement INSERT INTO tickets (title, status) VALUES (:title, :status) lưu an toàn, gọi commit() và trả về thực thể Ticket mới đã gắn id.<script> được bọc qua htmlspecialchars(..., ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') biến thành <script> vô hại.
Chúng ta sẽ khởi tạo dự án chuẩn PSR-4 với Composer và viết đầy đủ các lớp thuộc tầng Domain cùng Repository.
Bước 1: Tạo thư mục dự án và cấu hình composer.json
Mục đích: Khởi tạo cây thư mục src/Domain, src/Repository, tests và cấu hình ánh xạ PSR-4 "App\\": "src/".
WORKDIR="$(mktemp -d /tmp/php-ep22-XXXXXX)"
cd "$WORKDIR"
mkdir -p "$WORKDIR/src/Domain" "$WORKDIR/src/Repository" "$WORKDIR/tests"
cat <<'EOF' > "$WORKDIR/composer.json"
{
"name": "thanhnh/php-ep22-ticket-app",
"autoload": { "psr-4": { "App\\": "src/" } }
}
EOF
pwd
Bước 2: Tạo TicketStatus.php và thực thể Ticket.php sử dụng Property Hooks & private(set)
Mục đích: Xây dựng mô hình miền tự bảo vệ tính hợp lệ bằng các tính năng OOP mới nhất của PHP 8.4.
cat <<'EOF' > "$WORKDIR/src/Domain/TicketStatus.php"
<?php
declare(strict_types=1);
namespace App\Domain;
enum TicketStatus: string
{
case Open = 'open';
case Resolved = 'resolved';
}
EOF
cat <<'EOF' > "$WORKDIR/src/Domain/Ticket.php"
<?php
declare(strict_types=1);
namespace App\Domain;
use InvalidArgumentException;
final class Ticket
{
public string $title {
set(string $value) {
$clean = mb_trim($value);
if (mb_strlen($clean, 'UTF-8') < 3) {
throw new InvalidArgumentException('Title too short');
}
$this->title = $clean;
}
}
public private(set) TicketStatus $status;
public function __construct(
public readonly ?int $id,
string $title,
TicketStatus $status = TicketStatus::Open,
) {
$this->title = $title;
$this->status = $status;
}
public function resolve(): void
{
$this->status = TicketStatus::Resolved;
}
}
EOF
Bước 3: Tạo TicketRepository.php quản lý lưu trữ PDO SQLite có Transaction
Mục đích: Đóng gói câu lệnh khởi tạo bảng, lưu trữ trong giao dịch và truy vấn danh sách ánh xạ sang đối tượng Ticket.
cat <<'EOF' > "$WORKDIR/src/Repository/TicketRepository.php"
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Domain\Ticket;
use App\Domain\TicketStatus;
use PDO;
final readonly class TicketRepository
{
public function __construct(private PDO $pdo) {}
public function migrate(): void
{
$this->pdo->exec('CREATE TABLE IF NOT EXISTS tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
status TEXT NOT NULL
)');
}
public function save(Ticket $ticket): Ticket
{
$this->pdo->beginTransaction();
try {
$stmt = $this->pdo->prepare('INSERT INTO tickets (title, status) VALUES (:title, :status)');
$stmt->execute([':title' => $ticket->title, ':status' => $ticket->status->value]);
$id = (int) $this->pdo->lastInsertId();
$this->pdo->commit();
return new Ticket($id, $ticket->title, $ticket->status);
} catch (\Throwable $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $e;
}
}
/** @return list<Ticket> */
public function findAll(): array
{
$rows = $this->pdo->query('SELECT id, title, status FROM tickets ORDER BY id ASC')->fetchAll();
return array_map(
fn(array $r): Ticket => new Ticket((int) $r['id'], (string) $r['title'], TicketStatus::from((string) $r['status'])),
$rows
);
}
}
EOF
composer dump-autoload --working-dir="$WORKDIR" --quiet
Cảnh báo an toàn: Trong phương thức save(), nhờ khối catch (\Throwable $e) kiểm tra $this->pdo->inTransaction() và gọi rollBack() trước khi ném lại $e, kết nối DB luôn quay về trạng thái sạch.
Chúng ta sẽ viết kịch bản kiểm thử tích hợp tests/app_test.php: khởi tạo SQLite :memory:, lưu phiếu hỗ trợ chứa khoảng trắng Unicode U+2003 và thẻ <script>, gọi resolve(), dùng array_find() của PHP 8.4 tìm phiếu đang mở từ DB và kiểm tra chuỗi tiêu đề đã thoát mã HTML.
Bước 4: Tạo và chạy kịch bản kiểm thử tests/app_test.php
Mục đích: Xác minh toàn bộ luồng từ Autoloading, Property Hooks, Enum, PDO Repository, array_find() đến htmlspecialchars() vận hành chính xác.
cat <<'EOF' > "$WORKDIR/tests/app_test.php"
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use App\Domain\Ticket;
use App\Domain\TicketStatus;
use App\Repository\TicketRepository;
$pdo = new PDO('sqlite::memory:', null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$repo = new TicketRepository($pdo);
$repo->migrate();
$t1 = $repo->save(new Ticket(null, " \u{2003}Lỗi kết nối VPN <script> "));
$t1->resolve();
$all = $repo->findAll();
$firstOpen = array_find($all, fn(Ticket $t): bool => $t->status === TicketStatus::Open);
$escapedTitle = htmlspecialchars($firstOpen->title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
echo "SavedID={$firstOpen->id} | MemoryStatus={$t1->status->value} | DBStatus={$firstOpen->status->value} | SafeTitle={$escapedTitle}\n";
EOF
php "$WORKDIR/tests/app_test.php"
Kết quả quan sát được:
SavedID=1 | MemoryStatus=resolved | DBStatus=open | SafeTitle=Lỗi kết nối VPN <script>.$t1 trong bộ nhớ sau khi gọi $t1->resolve() đã chuyển sang MemoryStatus=resolved, trong khi bản ghi đọc lại từ cơ sở dữ liệu $firstOpen vẫn giữ DBStatus=open, thể hiện rõ sự tách biệt giữa Domain Entity trong RAM và tầng lưu trữ Persistence.Chúng ta sẽ kiểm tra hai rào chắn bảo vệ của thực thể Ticket: truyền tiêu đề quá ngắn sau khi cắt khoảng trắng Unicode và cố tình gán trực tiếp $t->status từ bên ngoài lớp.
Bước 5: Kiểm chứng ngoại lệ từ Property Hook set và lỗi private(set)
Mục đích: Xác nhận cả hai hành vi vi phạm hợp đồng miền đều bị chặn đứng ngay lập tức.
set +e
php -r '
require "'"$WORKDIR"'/vendor/autoload.php";
new App\Domain\Ticket(null, " \u{2003}ab ");
'
RC1=$?
php -r '
require "'"$WORKDIR"'/vendor/autoload.php";
$t = new App\Domain\Ticket(1, "Hợp lệ");
$t->status = App\Domain\TicketStatus::Resolved;
'
RC2=$?
set -e
echo "RC1=$RC1 | RC2=$RC2"
Kết quả quan sát được:
RC1=255 | RC2=255: lệnh thứ nhất ném InvalidArgumentException: Title too short, còn lệnh thứ hai ném Error: Cannot modify private(set) property App\Domain\Ticket::$status from global scope.
Bước 6: Dọn dẹp vùng làm việc tạm
Mục đích: Giải phóng thư mục dự án thử nghiệm trong /tmp.
rm -rf "$WORKDIR"
Chúc mừng bạn đã hoàn thành trọn vẹn 22 bài học trong khóa học PHP (lấy PHP 8.4 làm mốc tham chiếu chuẩn)! Từ nền tảng vững chắc đã kiểm chứng trong container cô lập, bạn đã sẵn sàng tiến lên các bước tiếp theo:
Hãy tự đối chiếu 4 câu hỏi tổng kết toàn khóa dưới đây:
Ticket, vì sao kết hợp mb_trim() và mb_strlen($clean, 'UTF-8') bên trong Property Hook set của $title an toàn hơn kiểm tra strlen(trim($title)) bên ngoài? -> Vì mb_trim() cắt cả khoảng trắng Unicode, mb_strlen() đếm đúng số ký tự tiếng Việt, và đặt trong set hook đảm bảo mọi nơi gán $ticket->title đều phải qua chốt kiểm tra này.htmlspecialchars()? -> Prepared Statements bảo vệ tại biên Cơ sở dữ liệu (chống SQL Injection), còn htmlspecialchars() bảo vệ tại biên hiển thị HTML (chống XSS).$t1->resolve() trong bộ nhớ, bản ghi trong SQLite vẫn mang trạng thái 'open' cho tới khi gọi phương thức cập nhật của Repository? -> Vì đối tượng Ticket chỉ quản lý trạng thái nghiệp vụ trong RAM, tách biệt với câu lệnh SQL xuống DB.declare(strict_types=1); ở dòng đầu, khai báo kiểu tường minh cho mọi thành phần, và viết kiểm thử tự động cho cả nhánh thành công lẫn nhánh lỗi.Bạn đã hoàn tất toàn bộ lộ trình 22 bài học PHP trên thanhnh.id.vn/docs. Hãy sử dụng danh sách liên kết bên dưới để tra cứu lại bất kỳ chủ đề nào trong suốt quá trình làm việc thực tế!
Ôn bài trước: PHP EP 21: Tổ chức dự án và kiểm thử tự động 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.

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.