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

PHP EP 22: Xây dựng ứng dụng Ticket bằng PHP và PDO

PHP EP 22: Xây dựng ứng dụng Ticket bằng PHP và PDO

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ử.

Ghép kiến thức thành mô hình Ticket có 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):

  • Ngày kiểm chứng: 2026-10-26.
  • Môi trường thực thi: Container cô lập php-course-qa:8.4 (--network none, --read-only, --tmpfs /tmp:rw,exec,nosuid,size=64m, user phplab UID 1001).
  • Phiên bản runtime: PHP 8.4.27 (cli), Composer 2.10.3 và SQLite 3.53.4 (pdo_sqlite) trên Alpine Linux 3.21 (aarch64).
  • Giới hạn kiểm chứng: Kiểm chứng toàn bộ luồng nghiệp vụ, lưu trữ PDO SQLite, trình kết xuất chống XSS và bộ kiểm thử tự động trong container cô lập; làm nền tảng chuẩn mực trước khi tiến lên các Framework lớn như Laravel hay Symfony.

Ánh xạ kỹ thuật PHP vào kiến trúc Ticket

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.

Domain và Repository phối hợp như thế nào

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:

  1. Khi dữ liệu đi vào 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ớ.
  2. Thuộc tính trạng thái khai báo là 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.
  3. Khi gọi $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.
  4. Khi hiển thị ra HTML, tiêu đề chứa <script> được bọc qua htmlspecialchars(..., ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') biến thành &lt;script&gt; vô hại.
Luồng xử lý đầu-cuối của ứng dụng Ticket từ giao diện HTTP/CLI xuống cơ sở dữ liệu.
Hình 1. Luồng xử lý đầu-cuối của ứng dụng Ticket từ giao diện HTTP/CLI xuống cơ sở dữ liệu. 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.

Tạo Domain và Repository cho Ticket

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.

Kiểm thử tích hợp Domain và Repository

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:

  • Đầu ra hiển thị: SavedID=1 | MemoryStatus=resolved | DBStatus=open | SafeTitle=Lỗi kết nối VPN &lt;script&gt;.
  • Biến $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.

Từ chối tiêu đề rỗng và thay đổi trạng thái trái phép

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:

  • Cả hai lệnh đều bị chặn với 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ản đồ kết nối các thành phần kiến thức PHP đã học trong bài thực hành tổng hợp.
Hình 2. Bản đồ kết nối các thành phần kiến thức PHP đã học trong bài thực hành tổng hợp. 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.

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"

Các phần cần bổ sung trước khi lên production

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:

  1. Làm chủ các tiêu chuẩn PHP-FIG nâng cao: Tìm hiểu PSR-7 (HTTP Message), PSR-11 (DI Container), PSR-15 (Middleware) và PSR-3 (Logger).
  2. Tiếp cận nhanh các Framework hiện đại (Laravel / Symfony): Hiểu rõ bản chất bên dưới Eloquent/Doctrine (PDO Prepared Statements & Transactions) và Service Container (Reflection & PSR-4).
  3. Vận hành Production hiệu năng cao: Tinh chỉnh OPcache / JIT, quản lý PHP-FPM hoặc các application server như FrankenPHP và RoadRunner.

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

Hãy tự đối chiếu 4 câu hỏi tổng kết toàn khóa dưới đây:

  • Câu hỏi 1: Trong thực thể 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.
  • Câu hỏi 2: Khi nào dữ liệu văn bản được bảo vệ bởi Prepared Statements và khi nào cần 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).
  • Câu hỏi 3: Vì sao sau khi gọi $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.
  • Câu hỏi 4: Ba thói quen cốt lõi nào cần duy trì ở mọi tệp mã nguồn PHP 8.4? -> Đặt 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.

Bài liên quan trong bộ PHP

Tìm hiểu thêm