EzCode # Bài 13 — Chi tiết khóa học và danh sách bài học
Học xong bài này bạn sẽ:
- Dùng
INNER JOINđể lấy dữ liệu từ hai bảng trong một câu truy vấn- Tạo
Models/Lession.phpvà phương thứcdetail($id)nhận tham số từ URL- Có trang chi tiết khóa học với danh sách bài học dạng accordion bấm mở được
1. Vấn đề đặt ra
Trang chi tiết khóa học cần hiển thị tên giảng viên.
Nhưng mở bảng courses ra, bạn chỉ thấy cột teacher_id chứa một con số:
id | title | teacher_id | price
1 | Khóa học HTML CSS cơ bản | 1 | 300000
Số 1 không giúp gì cho người xem. Tên thật nằm ở bảng khác:
id | name | email
1 | Nguyễn Văn A | teacher1@example.com
Bạn có thể làm hai truy vấn: một lấy khóa học, một lấy giảng viên theo teacher_id. Cách này chạy được, nhưng:
- Hai lần đi lại với database thay vì một
- Với danh sách 14 khóa học thì thành 15 truy vấn — đúng vấn đề "N+1" mà Bài 11 đã cảnh báo
SQL có cách làm tốt hơn: nối hai bảng lại trong một câu truy vấn. Đó là JOIN, và nó là kỹ thuật SQL quan trọng nhất bạn học trong giáo trình này.
2. Kiến thức mới
2.1. INNER JOIN — nối hai bảng
SELECT c.*, u.name AS teacher_name
FROM courses c
INNER JOIN users u ON c.teacher_id = u.id
WHERE c.id = 1
Đọc câu này thành tiếng Việt: "Lấy mọi cột của bảng courses và cột name của bảng users, từ bảng courses, ghép với bảng users ở chỗ courses.teacher_id bằng users.id, chỉ lấy khóa học có id bằng 1."
Cách MySQL thực hiện, hình dung từng bước:
Bảng courses (rút gọn):
| id | title | teacher_id |
|---|---|---|
| 1 | HTML CSS | 1 |
| 2 | Flutter | 1 |
| 3 | Khoa học máy tính | 5 |
Bảng users (rút gọn):
| id | name |
|---|---|
| 1 | Nguyễn Văn A |
| 5 | Ngô Văn E |
Với mỗi dòng của courses, MySQL tìm dòng trong users thỏa mãn c.teacher_id = u.id rồi ghép hai dòng thành một:
| id | title | teacher_id | teacher_name |
|---|---|---|---|
| 1 | HTML CSS | 1 | Nguyễn Văn A |
| 2 | Flutter | 1 | Nguyễn Văn A |
| 3 | Khoa học máy tính | 5 | Ngô Văn E |
Kết quả về tới PHP là mảng kết hợp có thêm khóa teacher_name — dùng như mọi cột khác:
echo $courseDetail['title']; // HTML CSS
echo $courseDetail['teacher_name']; // Nguyễn Văn A
2.2. Chữ "INNER" nghĩa là gì
INNER JOIN chỉ giữ những dòng có cặp khớp ở cả hai bên.
Nếu một khóa học có teacher_id = 999 mà bảng users không có ai id 999, thì khóa học đó biến mất khỏi kết quả.
Với EzCode thì không xảy ra, vì khóa ngoại ở Bài 7 mục 2.5 đã bắt buộc teacher_id phải tồn tại trong users. Nhưng bạn cần biết hành vi này.
Các loại JOIN khác:
| Loại | Giữ lại gì |
|---|---|
INNER JOIN |
Chỉ dòng khớp ở cả hai bảng |
LEFT JOIN |
Mọi dòng bảng trái, bên phải thiếu thì điền NULL |
RIGHT JOIN |
Ngược lại LEFT JOIN |
EzCode chỉ dùng INNER JOIN, ở 8 truy vấn khác nhau.
Khi nào cần LEFT JOIN? Ví dụ muốn liệt kê mọi khóa học kèm số học viên, kể cả khóa chưa ai mua. INNER JOIN với bảng enrollments sẽ loại bỏ khóa chưa ai mua; LEFT JOIN giữ lại và cho số 0.
2.3. Bí danh bảng và bí danh cột
Bí danh bảng đặt ngay sau tên bảng:
FROM courses c
INNER JOIN users u ON c.teacher_id = u.id
c thay cho courses, u thay cho users. Từ đó viết c.title thay vì courses.title.
Với một bảng thì bí danh chỉ để gõ ít hơn. Nhưng khi nối nhiều bảng, nó bắt buộc để tránh nhập nhằng: cả courses lẫn users đều có cột id, viết id trần thì MySQL báo lỗi Column 'id' in field list is ambiguous.
Bí danh cột đặt bằng từ khóa AS:
u.name AS teacher_name
Cần thiết vì bảng courses không có cột name, nhưng nếu sau này có thì hai cột cùng tên sẽ đè lên nhau trong mảng kết quả. Đổi tên thành teacher_name là tránh hẳn rủi ro đó.
Từ khóa AS có thể bỏ: u.name teacher_name cũng chạy. Nhưng viết ra thì rõ hơn.
2.4. c.* — lấy mọi cột của một bảng
SELECT c.*, u.name AS teacher_name
c.* nghĩa là "mọi cột của bảng có bí danh c". Kết quả gồm 8 cột của courses cộng thêm 1 cột teacher_name.
So sánh:
| Cách viết | Kết quả |
|---|---|
SELECT * |
Mọi cột của cả hai bảng — gồm cả password của giảng viên |
SELECT c.* |
Chỉ 8 cột của courses |
SELECT c.*, u.name AS teacher_name |
8 cột của courses + 1 cột tên giảng viên |
Cách thứ nhất nguy hiểm: nó kéo cả chuỗi băm mật khẩu vào bộ nhớ PHP mà không ai cần tới. Tác giả EzCode dùng đúng cách thứ ba.
2.5. Hai phương thức gần giống nhau
Models/Courses.php có hai phương thức lấy khóa học theo id, chỉ khác nhau một điều kiện:
// Dùng cho người xem
"... WHERE c.id=? AND c.status = 1"
// Dùng cho giảng viên
"... WHERE c.id=?"
Vì sao cần hai bản?
- Người xem không được nhìn thấy khóa học đang ẩn. Giảng viên tắt khóa học đi là nó biến mất khỏi trang công khai.
- Giảng viên phải sửa được khóa học của mình kể cả khi đang ẩn. Nếu không, ẩn đi rồi thì không bao giờ bật lại được.
Thiết kế này đúng. Nhưng nó tạo ra một cái bẫy: lập trình viên phải nhớ dùng đúng bản ở đúng chỗ.
⚠️ Và EzCode đã sập bẫy đó.
TeacherController::deleteCourse()vàmanageLessons()gọigetById()— bản dành cho người xem, có lọcstatus = 1.Hậu quả: giảng viên không xóa được và không quản lý bài học được cho khóa học đang ẩn. Bấm nút thì bị đá về danh sách mà không báo lý do.
Bạn sẽ gặp lỗi này ở Bài 18. Đây là lỗi số 8 trong Bài 22.
2.6. ORDER BY nhiều tiêu chí
SELECT * FROM lessions WHERE course_id=? ORDER BY sort_order ASC, id ASC
Hai tiêu chí cách nhau bởi dấu phẩy. MySQL sắp theo sort_order trước; những dòng có sort_order bằng nhau thì mới xét tới id.
Vì sao cần tiêu chí thứ hai? Vì cột sort_order có DEFAULT 999 (Bài 7). Nếu giảng viên tạo 5 bài học mà không đặt thứ tự, cả 5 đều có sort_order = 999 và MySQL sẽ trả về theo thứ tự tùy ý — có thể khác nhau giữa các lần chạy.
Thêm id ASC bảo đảm: cùng sort_order thì bài tạo trước lên trước. Kết quả luôn ổn định.
Nguyên tắc chung: khi sắp xếp theo một cột có thể trùng giá trị, luôn thêm một tiêu chí phụ duy nhất — thường là khóa chính.
2.7. Nhận tham số từ URL
function detail($id)
Giá trị $id đến từ đâu? Nhớ lại index.php ở Bài 9:
$args = [];
if (isset($_GET['id'])) {
$args[] = $_GET['id'];
}
$ctrl->$act(...$args);
Với địa chỉ ?ctrl=course&act=detail&id=1, $args là ["1"], và lời gọi thành $ctrl->detail("1").
Chú ý ba điều:
- Tham số bắt buộc phải tên là
idtrên URL. Đặt tên khác là không tới nơi — đúng lỗi số 1 mà Bài 9 bài tập 3 đã chứng minh. - Giá trị nhận được là chuỗi
"1", không phải số. - Người dùng kiểm soát được giá trị này. Họ gõ
?id=999hay?id=abcđược, nên code phải chịu được.
2.8. Accordion — đóng mở nội dung
Accordion là kiểu giao diện có nhiều mục, bấm vào tiêu đề thì nội dung mở ra.
Cấu trúc HTML:
<div class="accordion-item">
<button class="accordion-btn">Giới thiệu về HTML</button>
<div class="accordion-content">
<p>HTML là ngôn ngữ đánh dấu...</p>
</div>
</div>
Ba khái niệm JavaScript được dùng:
nextElementSibling — lấy phần tử ngay sau phần tử hiện tại, cùng cấp.
const content = btn.nextElementSibling;
Nút bấm và khối nội dung là hai anh em liền kề, nên từ nút lấy được nội dung tương ứng mà không cần id riêng cho từng mục.
scrollHeight — chiều cao thật của nội dung, kể cả phần đang bị ẩn.
style.maxHeight — chiều cao tối đa cho phép. CSS đặt max-height: 0 và overflow: hidden nên mặc định nội dung bị giấu.
Cách đóng mở:
if (content.style.maxHeight) {
content.style.maxHeight = null; // đóng
} else {
content.style.maxHeight = content.scrollHeight + "px"; // mở
}
Vì sao không dùng display: none cho đơn giản? Vì display không có hiệu ứng chuyển động mượt. CSS có transition: max-height 0.3s nên cách này cho ra hiệu ứng trượt xuống.
3. Áp dụng vào EzCode
3.1. Thêm hai phương thức vào Model Courses
Chèn vào Models/Courses.php, sau getAll() và trước filter().
📁
Models/Courses.php— sửa
function getById($id)
{
$sql = "SELECT c.* , u.name AS teacher_name FROM courses c INNER JOIN users u ON c.teacher_id = u.id WHERE c.id=? AND c.status = 1";
return $this->db->queryOne($sql, $id);
}
function getByIdForTeacher($id)
{
$sql = "SELECT c.* , u.name AS teacher_name FROM courses c INNER JOIN users u ON c.teacher_id = u.id WHERE c.id=?";
return $this->db->queryOne($sql, $id);
}
Hai phương thức khác nhau đúng đoạn AND c.status = 1, như mục 2.5 đã giải thích.
Cả hai dùng queryOne() chứ không phải query(), vì WHERE c.id=? với id là khóa chính thì tối đa một dòng khớp. Nhớ lại Bài 8 mục 2.7: queryOne() trả về một mảng kết hợp, hoặc false nếu không tìm thấy.
Chú ý khoảng trắng thừa trong c.* , — tác giả gõ dấu cách trước dấu phẩy. Không ảnh hưởng gì, cứ gõ y hệt.
getByIdForTeacher() chưa dùng tới ở bài này. Bài 18 mới cần. Ta viết trước cho gọn.
3.2. Tạo Model Lession
📁
Models/Lession.php— tạo mới
<?php
include_once "./Models/Database.php";
class Lession
{
private $db;
function __construct()
{
$this->db = new Database();
}
function getAllByCourse($course_id)
{
$sql = "SELECT * FROM lessions WHERE course_id=? ORDER BY sort_order ASC, id ASC";
return $this->db->query($sql, $course_id);
}
Chú ý: khối trên chưa đóng class. Thêm một dòng
}ở cuối file.Bài 19 sẽ thêm 5 phương thức nữa:
getById,create,update,delete,countByCourse.
Về tên Lession: đây là viết sai chính tả của Lesson. Tác giả gõ nhầm từ đầu và giữ nguyên trong cả tên bảng lessions, tên file Lession.php, tên class Lession, và mọi câu truy vấn.
Bạn bắt buộc phải gõ y hệt cái sai đó. Sửa cho đúng chính tả là code không chạy, vì bảng trong database tên là lessions.
Đây là bài học thực tế: một lỗi chính tả lúc thiết kế database sẽ đi theo project suốt đời. Đổi tên bảng sau khi đã có dữ liệu và code là việc tốn kém, nên người ta thường chấp nhận sống chung với nó.
3.3. Thêm phương thức detail vào Controller
Chèn vào Controllers/CourseController.php, sau phương thức list().
📁
Controllers/CourseController.php— sửa
function detail($id)
{
include_once "./Models/Courses.php"; // Include the Courses model
$coursesModel = new Courses(); // Create an instance of the Courses model
$courseDetail = $coursesModel->getById($id); // Fetch course details by ID
include_once "./Models/Lession.php"; // Include the Lession model
$lessionModel = new Lession(); // Create an instance of the Lession model
$lessionsList = $lessionModel->getAllByCourse($id); // Fetch all lessons for the course
// Assuming course_detail.php is the view for displaying course details
include_once "./Views/course_detail.php";
Chú ý: nhớ có dấu
}đóng phương thức sau khối này, trước dấu}đóng class.
Cùng khuôn mẫu hai Model như Bài 11: nạp, tạo object, lấy dữ liệu. Rồi nạp một View dùng cả hai biến.
Hãy để ý phương thức này khai báo function detail($id) không có từ khóa public, trong khi list() thì có. PHP mặc định là public nên không khác gì. Lại một sự thiếu nhất quán về phong cách của tác giả.
⚠️ Lưu ý: Phương thức này không kiểm tra
$courseDetailcó phảifalsekhông trước khi nạp View.Mở
?ctrl=course&act=detail&id=999— một id không tồn tại — bạn sẽ thấy hàng loạt cảnh báo:Warning: Trying to access array offset on value of type boolTrang vẫn hiện ra nhưng trống rỗng và đầy lỗi. Đúng ra phải chuyển hướng về danh sách kèm thông báo.
Vấn đề này nằm ở mục ghi chú cuối Bài 22, và bài tập 3 dưới đây chính là bản vá.
3.4. Tạo View chi tiết
📁
Views/course_detail.php— tạo mới
<main class="course-detail">
<div class="detail-container">
<img src="public/img/courses/<?= $courseDetail['image'] ?>" alt="Hình ảnh khóa học" />
<div class="detail-info">
<h2><?= $courseDetail['title'] ?></h2>
<p class="price">Giá: <?= number_format($courseDetail['price']) ?></p>
<p class="teacher">Giảng viên: <?= $courseDetail['teacher_name'] ?></p>
<p class="description">
<?= $courseDetail['description'] ?>
</p>
<!-- Hiện 1 trong 2 nút sau -->
<a href="?ctrl=course&act=register&id=<?=$courseDetail['id']?>" class="btn">Đăng ký ngay</a>
<a href="?ctrl=learn&act=detail&id=<?=$courseDetail['id']?>" class="btn">Vào học</a>
</div>
</div>
<section class="lesson-section">
<h3>Danh sách bài học</h3>
<?php foreach ($lessionsList as $lessions) : ?>
<div class="accordion">
<div class="accordion-item">
<button class="accordion-btn"><?= $lessions['title'] ?></button>
<div class="accordion-content">
<p><?= $lessions['content'] ?></p>
</div>
</div>
<!-- Thêm nhiều bài học khác theo mẫu -->
</div>
<?php endforeach; ?>
</section>
</main>
Dòng <?= $courseDetail['teacher_name'] ?> là kết quả trực tiếp của INNER JOIN. Không có JOIN, khóa này không tồn tại trong mảng và bạn sẽ nhận Warning: Undefined array key "teacher_name".
Ghi chú <!-- Hiện 1 trong 2 nút sau --> cho thấy ý định của tác giả: chỉ hiện "Đăng ký ngay" khi chưa mua, và "Vào học" khi đã mua.
Nhưng code hiện cả hai cùng lúc. Chức năng kiểm tra đã mua chưa nằm ở User::checkEnrollment(), mà phương thức đó tới Bài 21 mới có.
⚠️ Lưu ý: Việc hiện cả hai nút không chỉ xấu về giao diện. Nút "Vào học" trỏ tới
?ctrl=learn&act=detail&id=..., vàLearnControllerchỉ kiểm tra đã đăng nhập, không kiểm tra đã mua.Nghĩa là bất kỳ ai có tài khoản đều bấm vào và học miễn phí toàn bộ khóa học. Đây là lỗi số 11 trong Bài 22, và là lỗ hổng nghiêm trọng nhất của cả project — nó phá vỡ hoàn toàn mô hình kinh doanh.
Biến tạm trong vòng lặp đặt tên $lessions số nhiều, giống lỗi đặt tên $categories ở Bài 11. Đúng ra là $lession. Cứ gõ y hệt.
⚠️ Lưu ý:
<?= $courseDetail['description'] ?>in mô tả ra mà không lọc HTML. Mô tả do giảng viên nhập vào ở Bài 18.Nghĩa là một giảng viên có thể nhập mô tả chứa
<script>và đoạn mã đó sẽ chạy trên máy mọi người xem khóa học. Đây là lỗi số 10 trong Bài 22, và ở đây nó nguy hiểm hơn hẳn so với chỗ hiện tên khóa học, vì mô tả là ô nhập tự do dài.
3.5. Đoạn accordion trong script.js
Bạn đã chép file script.js vào từ Bài 10. Giờ xem đoạn thứ hai của nó.
📁
public/js/script.js— đọc trước
try {
const accButtons = document.querySelectorAll(".accordion-btn");
accButtons.forEach((btn) => {
btn.addEventListener("click", () => {
const content = btn.nextElementSibling;
// Đóng tất cả accordion khác
document.querySelectorAll(".accordion-content").forEach((other) => {
if (other !== content) {
other.style.maxHeight = null;
}
});
// Mở accordion hiện tại
if (content.style.maxHeight) {
content.style.maxHeight = null;
} else {
content.style.maxHeight = content.scrollHeight + "px";
}
});
});
} catch (error) {}
Cách hoạt động:
querySelectorAll(".accordion-btn")lấy mọi nút bài học trên trang.forEachgắn cho mỗi nút một hàm chạy khi bấm.- Khi bấm,
nextElementSiblinglấy khối nội dung ngay dưới nút đó. - Vòng lặp thứ hai đóng mọi khối khác — đó là lý do accordion chỉ mở được một mục tại một thời điểm.
- Cuối cùng đóng hoặc mở khối hiện tại tùy trạng thái.
Ký hiệu (btn) => { ... } là hàm mũi tên, cách viết gọn của function(btn) { ... } trong JavaScript hiện đại.
Một điểm về cấu trúc HTML. Trong View, thẻ <div class="accordion"> nằm bên trong vòng lặp, nghĩa là mỗi bài học có một .accordion riêng chứa đúng một .accordion-item.
Đúng ra .accordion nên nằm ngoài vòng lặp và bao cả nhóm. Không gây lỗi vì JavaScript làm việc trực tiếp với .accordion-btn, nhưng CSS nào nhắm vào .accordion sẽ áp dụng cho từng mục thay vì cả nhóm.
4. Chạy thử
Mở ?ctrl=course&act=detail&id=1, hoặc bấm nút "Đăng ký" ở bất kỳ khóa học nào trên trang chủ.
Kết quả đúng với id=1:
- Ảnh khóa học ở trái (có thể vỡ — lỗi số 17)
- Tiêu đề "Khóa học HTML CSS cơ bản"
Giá: 300,000Giảng viên: Nguyễn Văn A— đây là bằng chứngINNER JOINhoạt động- Mô tả "Học cách xây dựng giao diện web từ đầu."
- Hai nút "Đăng ký ngay" và "Vào học"
- Tiêu đề "Danh sách bài học" và 2 nút bài học: "Giới thiệu về HTML" và "CSS cơ bản"
Bấm vào một tên bài học — nội dung trượt mở ra. Bấm vào bài kia — bài trước tự đóng, bài mới mở ra.
Thử id=2: khóa Flutter, giảng viên vẫn là Nguyễn Văn A, có 2 bài học khác.
Thử id=3: khóa "Khoa học máy tính cơ bản", giảng viên "Ngô Văn E", và phần bài học trống vì bảng lessions chỉ có dữ liệu cho khóa 1 và 2. Trang không lỗi, chỉ là danh sách rỗng — foreach trên mảng rỗng chạy 0 lần.
Thử id=999: trang đầy cảnh báo Trying to access array offset on value of type bool. Đúng như hộp cảnh báo ở mục 3.3. Bài tập 3 sẽ sửa.
Kiểm chứng INNER JOIN trong phpMyAdmin. Vào tab SQL và chạy:
SELECT c.* , u.name AS teacher_name
FROM courses c
INNER JOIN users u ON c.teacher_id = u.id
WHERE c.id=1 AND c.status = 1;
Bạn thấy đúng một dòng với 9 cột: 8 cột của courses cộng teacher_name. Đây chính xác là mảng mà PHP nhận được.
5. Bài tập
Bài tập 1
Thêm vào Models/Lession.php phương thức:
function countByCourse($course_id)
Nó trả về số bài học của một khóa, dùng COUNT(*) như bạn đã làm ở Bài 11 bài tập 3.
Sau đó hiển thị trên trang chi tiết: Khóa học gồm 2 bài.
Bài tập 2
Sửa câu SQL của getById() để lấy thêm tên danh mục, bằng cách nối thêm bảng categories.
Hiển thị nó trên trang chi tiết dưới dòng tên giảng viên.
Gợi ý: một câu
SELECTcó thể có nhiềuINNER JOINnối tiếp nhau. Bí danh cho bảngcategoriesnên làcatchứ không phảic— vìcđã dùng chocourses.
Bài tập 3
Sửa CourseController::detail() để xử lý trường hợp khóa học không tồn tại:
- Nếu
getById()trả vềfalse, ghi$_SESSION['error']rồi chuyển hướng về?ctrl=course&act=list - Nhớ có
exit()sauheader()
Thử với ?id=999 để kiểm chứng.
Sau đó trả lời: thông báo lỗi bạn ghi vào $_SESSION['error'] sẽ hiện ở đâu, và vì sao?
6. Đáp án
Đáp án bài tập 1
📄 Models/Lession.php — thêm trước dấu } cuối
function countByCourse($course_id)
{
$sql = "SELECT COUNT(*) as total FROM lessions WHERE course_id=?";
$result = $this->db->queryOne($sql, $course_id);
return $result['total'] ?? 0;
}
📄 Controllers/CourseController.php — thêm vào detail()
$soBaiHoc = $lessionModel->countByCourse($id);
📄 Views/course_detail.php
<p class="teacher">Giảng viên: <?= $courseDetail['teacher_name'] ?></p>
<p>Khóa học gồm <?= $soBaiHoc ?> bài</p>
Đây là phương thức có thật trong repo — bạn vừa viết trước một phần của Bài 19.
Cách thay thế không cần truy vấn:
<p>Khóa học gồm <?= count($lessionsList) ?> bài</p>
Cũng cho ra 2, và không tốn thêm truy vấn nào vì $lessionsList đã có sẵn.
Đúng như nguyên tắc ở Bài 11: đã có mảng thì dùng count(). Phương thức countByCourse() chỉ đáng dùng khi bạn cần con số mà không cần danh sách — chẳng hạn ở bảng quản lý khóa học của giảng viên, nơi hiện 20 khóa mà không cần nội dung bài học của khóa nào.
Đáp án bài tập 2
📄 Models/Courses.php
function getById($id)
{
$sql = "SELECT c.* , u.name AS teacher_name, cat.name AS category_name
FROM courses c
INNER JOIN users u ON c.teacher_id = u.id
INNER JOIN categories cat ON c.category_id = cat.id
WHERE c.id=? AND c.status = 1";
return $this->db->queryOne($sql, $id);
}
📄 Views/course_detail.php
<p class="teacher">Giảng viên: <?= $courseDetail['teacher_name'] ?></p>
<p class="category">Danh mục: <?= $courseDetail['category_name'] ?></p>
Ba điểm quan trọng:
Thứ nhất, hai INNER JOIN nối tiếp nhau. MySQL nối courses với users trước, rồi nối kết quả đó với categories. Bạn có thể nối bao nhiêu bảng cũng được, miễn mỗi lần đều có mệnh đề ON.
Thứ hai, bí danh phải khác nhau. Nếu đặt categories c thì trùng với courses c và MySQL báo Not unique table/alias: 'c'.
Thứ ba, cat.name AS category_name là bắt buộc, không phải tùy chọn. Vì cả users lẫn categories đều có cột name. Không đổi tên, cột sau sẽ đè lên cột trước trong mảng kết quả và bạn mất teacher_name.
Hãy thử bỏ AS category_name đi rồi chạy — bạn sẽ thấy dòng "Giảng viên" hiện ra tên danh mục. Đây là loại lỗi rất khó hiểu nếu không biết nguyên nhân.
Vì sao repo không làm sẵn? Vì tác giả không hiển thị danh mục trên trang chi tiết. Đây là bổ sung của bạn, và nó cho thấy JOIN mở rộng dễ đến mức nào.
Đáp án bài tập 3
📄 Controllers/CourseController.php
function detail($id)
{
include_once "./Models/Courses.php";
$coursesModel = new Courses();
$courseDetail = $coursesModel->getById($id);
// Kiểm tra khóa học có tồn tại không
if (!$courseDetail) {
$_SESSION['error'] = "Khóa học không tồn tại!";
header("Location: ?ctrl=course&act=list");
exit();
}
include_once "./Models/Lession.php";
$lessionModel = new Lession();
$lessionsList = $lessionModel->getAllByCourse($id);
include_once "./Views/course_detail.php";
}
Thử ?ctrl=course&act=detail&id=999 — bạn bị đưa về trang danh sách thay vì thấy trang lỗi.
Câu hỏi: thông báo lỗi hiện ở đâu?
Câu trả lời: không hiện ở đâu cả.
Đây là một phát hiện thú vị. Bạn ghi $_SESSION['error'] đúng cách, nhưng Views/course_list.php — trang bạn chuyển hướng tới — không có khối hiển thị flash message.
Hãy mở lại View đó ở Bài 11 mục 3.4 và kiểm chứng: nó có form lọc, có lưới khóa học, nhưng không có khối <?php if (isset($_SESSION['error'])): ?> nào.
Hậu quả tệ hơn nữa: thông báo đó vẫn nằm trong session vì không ai unset() nó. Lần sau người dùng mở trang đăng nhập hay trang cá nhân — những trang có khối hiển thị — thông báo "Khóa học không tồn tại!" sẽ nhảy ra một cách vô duyên.
Cách sửa: thêm khối flash message vào Views/course_list.php, dùng đúng mẫu bạn học ở Bài 5 mục 2.5:
<div class="course-list">
<?php if (isset($_SESSION['error'])): ?>
<div style="background-color: #f8d7da; color: #721c24; padding: 10px; border-radius: 5px; margin-bottom: 15px;">
<?= $_SESSION['error'] ?>
</div>
<?php unset($_SESSION['error']); ?>
<?php endif; ?>
Đây không phải lỗi bạn tự tạo ra. Repo gốc đã có sẵn vấn đề này: CourseController::register() ở Bài 21 cũng ghi $_SESSION['error'] = "Khóa học không tồn tại!" rồi chuyển hướng về đúng trang không hiển thị được.
Bài học rút ra: ghi flash message thì phải bảo đảm trang đích hiển thị được nó. Cách chắc chắn nhất là đưa khối hiển thị vào layout_header.php để mọi trang đều có — một cải tiến bạn có thể tự làm.
➡️ Bài tiếp theo: Bài 14 — Đăng ký tài khoản
⬅️ Về mục lục · Bài trước
All rights reserved