Bài 1-29 đã dạy các thành phần MVC riêng lẻ. Bài 30 là mini project hoàn chỉnh — kết hợp tất cả: authentication, CRUD sinh viên/môn học/điểm, dashboard thống kê, phân quyền, export CSV. Bạn sẽ tổng hợp kiến thức, hiểu luồng dữ liệu end-to-end, và có một dự án thực tế để đưa vào portfolio.
1. Yêu cầu dự án
📋 Chức năng:
- ✅ Đăng nhập/đăng ký, quên mật khẩu
- ✅ Quản lý sinh viên (CRUD)
- ✅ Quản lý môn học (CRUD)
- ✅ Quản lý điểm (nhập, sửa, xóa)
- ✅ Dashboard với thống kê
- ✅ Export CSV, in danh sách
- ✅ Phân quyền: admin/user
- ✅ Search, filter, phân trang
2. Database Schema
-- Users
CREATE TABLE users (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100),
email VARCHAR(100) UNIQUE,
password VARCHAR(255),
role ENUM('user', 'admin') DEFAULT 'user',
is_active TINYINT DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Sinh viên
CREATE TABLE sinhvien (
id INT PRIMARY KEY AUTO_INCREMENT,
mssv VARCHAR(20) UNIQUE,
ho_ten VARCHAR(100),
email VARCHAR(100),
lop VARCHAR(20),
diem_tb DECIMAL(3,2),
ghi_chu TEXT,
user_id INT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
-- Môn học
CREATE TABLE monhoc (
id INT PRIMARY KEY AUTO_INCREMENT,
ma_mon VARCHAR(20) UNIQUE,
ten_mon VARCHAR(100),
so_tin_chi INT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Điểm
CREATE TABLE diem (
id INT PRIMARY KEY AUTO_INCREMENT,
sinh_vien_id INT,
mon_hoc_id INT,
diem_giua_ky DECIMAL(3,2),
diem_cuoi_ky DECIMAL(3,2),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (sinh_vien_id) REFERENCES sinhvien(id) ON DELETE CASCADE,
FOREIGN KEY (mon_hoc_id) REFERENCES monhoc(id)
);3. Routes
<?php
// File: config/routes.php (HOÀN CHỈNH)
use App\Core\Router;
$router = new Router();
// ===== PUBLIC ROUTES =====
$router->get('/', 'HomeController@index')->name('home');
// ===== AUTH ROUTES =====
$router->group(['middleware' => 'Guest'], function($r) {
$r->get('/login', 'AuthController@loginForm')->name('login');
$r->post('/login', 'AuthController@login');
$r->get('/register', 'AuthController@registerForm')->name('register');
$r->post('/register', 'AuthController@register');
});
// ===== USER ROUTES =====
$router->group(['middleware' => 'Auth'], function($r) {
$r->get('/logout', 'AuthController@logout')->name('logout');
$r->get('/profile', 'AuthController@profile')->name('profile');
$r->post('/profile', 'AuthController@updateProfile');
$r->post('/change-password', 'AuthController@changePassword');
// Dashboard
$r->get('/dashboard', 'DashboardController@index')->name('dashboard');
// Sinh viên
$r->get('/sinhvien', 'SinhVienController@index')->name('sinhvien.index');
$r->get('/sinhvien/create', 'SinhVienController@create')->name('sinhvien.create');
$r->post('/sinhvien', 'SinhVienController@store')->name('sinhvien.store');
$r->get('/sinhvien/:id', 'SinhVienController@show')->name('sinhvien.show');
$r->get('/sinhvien/:id/edit', 'SinhVienController@edit')->name('sinhvien.edit');
$r->put('/sinhvien/:id', 'SinhVienController@update')->name('sinhvien.update');
$r->delete('/sinhvien/:id', 'SinhVienController@destroy')->name('sinhvien.destroy');
$r->get('/sinhvien/export/csv', 'SinhVienController@exportCsv')->name('sinhvien.export');
// Môn học
$r->get('/monhoc', 'MonHocController@index')->name('monhoc.index');
$r->get('/monhoc/create', 'MonHocController@create')->name('monhoc.create');
$r->post('/monhoc', 'MonHocController@store')->name('monhoc.store');
$r->get('/monhoc/:id/edit', 'MonHocController@edit')->name('monhoc.edit');
$r->put('/monhoc/:id', 'MonHocController@update')->name('monhoc.update');
$r->delete('/monhoc/:id', 'MonHocController@destroy')->name('monhoc.destroy');
// Điểm
$r->get('/diem', 'DiemController@index')->name('diem.index');
$r->get('/diem/nhap', 'DiemController@nhapDiem')->name('diem.nhap');
$r->post('/diem/luu', 'DiemController@luuDiem')->name('diem.luu');
$r->get('/diem/:id/edit', 'DiemController@edit')->name('diem.edit');
$r->put('/diem/:id', 'DiemController@update')->name('diem.update');
$r->delete('/diem/:id', 'DiemController@destroy')->name('diem.destroy');
});
// ===== ADMIN ROUTES =====
$router->group(['prefix' => 'admin', 'middleware' => 'Admin'], function($r) {
$r->get('/dashboard', 'Admin\DashboardController@index')->name('admin.dashboard');
$r->get('/users', 'Admin\UserController@index')->name('admin.users');
$r->get('/users/:id/edit', 'Admin\UserController@edit');
$r->put('/users/:id', 'Admin\UserController@update');
$r->delete('/users/:id', 'Admin\UserController@destroy');
});
return $router;
?>4. Dashboard Controller
<?php
// File: app/Controllers/DashboardController.php
namespace App\Controllers;
use App\Models\SinhVienModel;
use App\Models\DiemModel;
use App\Models\MonHocModel;
class DashboardController extends BaseController {
protected SinhVienModel $sinhVienModel;
protected DiemModel $diemModel;
protected MonHocModel $monHocModel;
public function __construct() {
parent::__construct();
$this->sinhVienModel = new SinhVienModel();
$this->diemModel = new DiemModel();
$this->monHocModel = new MonHocModel();
}
public function index(array $params): string {
// Thống kê chung
$stats = [
'total_students' => $this->sinhVienModel->count(),
'total_subjects' => $this->monHocModel->count(),
'excellent' => count($this->sinhVienModel->scopeExcellent()),
'good' => count($this->sinhVienModel->scopeGood()),
'failed' => count($this->sinhVienModel->scopeFailed()),
];
// Top 10 sinh viên xuất sắc
$topStudents = array_slice($this->sinhVienModel->scopeExcellent(), 0, 10);
// Thống kê theo lớp
$classes = $this->sinhVienModel->getAllClasses();
$classByStats = [];
foreach ($classes as $class) {
$students = $this->sinhVienModel->getByClass($class);
$classByStats[$class] = count($students);
}
return $this->view('dashboard.index', [
'title' => 'Dashboard',
'stats' => $stats,
'topStudents' => $topStudents,
'classByStats' => $classByStats,
]);
}
}
?>5. Sinh Viên Controller
<?php
// File: app/Controllers/SinhVienController.php
namespace App\Controllers;
use App\Models\SinhVienModel;
class SinhVienController extends BaseController {
protected SinhVienModel $model;
public function __construct() {
parent::__construct();
$this->model = new SinhVienModel();
}
public function index(array $params): string {
$page = (int)($_GET['page'] ?? 1);
$keyword = trim($_GET['q'] ?? '');
$class = trim($_GET['lop'] ?? '');
if ($keyword) {
$result = $this->model->search($keyword, $page);
} elseif ($class) {
$students = $this->model->getByClass($class);
$result = [
'items' => array_slice($students, ($page - 1) * 10, 10),
'total' => count($students),
'per_page' => 10,
'current_page' => $page,
'last_page' => ceil(count($students) / 10),
'from' => (($page - 1) * 10) + 1,
'to' => min($page * 10, count($students)),
];
} else {
$result = $this->model->paginate($page);
}
return $this->view('sinhvien.index', [
'title' => 'Danh Sách Sinh Viên',
'students' => $result['items'],
'pagination' => $result,
'keyword' => $keyword,
'class' => $class,
'classes' => $this->model->getAllClasses(),
]);
}
public function create(array $params): string {
return $this->view('sinhvien.form', [
'title' => 'Thêm Sinh Viên',
'action' => route('sinhvien.store'),
'method' => 'POST',
]);
}
public function store(array $params): void {
$errors = $this->validate([
'mssv' => 'required|max:20|unique:sinhvien',
'ho_ten' => 'required|min:2|max:100',
'email' => 'required|email|unique:sinhvien',
'lop' => 'required|max:20',
]);
if (!empty($errors)) {
$this->withErrors($errors);
setOldInput($_POST);
$this->redirect(route('sinhvien.create'));
}
try {
$data = $this->model->normalizeData($_POST);
$id = $this->model->create($data);
clearOldInput();
flash('success', 'Thêm sinh viên thành công!');
$this->redirect(route('sinhvien.show', ['id' => $id]));
} catch (\Exception $e) {
logMessage('ERROR', $e->getMessage());
flash('error', 'Có lỗi xảy ra.');
$this->back();
}
}
public function show(array $params): string {
$id = (int)$params['id'];
$student = $this->model->find($id);
if (!$student) {
http_response_code(404);
return view('errors.404', [], null);
}
$grades = $this->model->getDiem($id);
return $this->view('sinhvien.detail', [
'title' => 'Chi Tiết Sinh Viên',
'student' => $student,
'grades' => $grades,
]);
}
public function edit(array $params): string {
$id = (int)$params['id'];
$student = $this->model->find($id);
if (!$student) {
flash('error', 'Không tìm thấy sinh viên');
$this->redirect(route('sinhvien.index'));
}
return $this->view('sinhvien.form', [
'title' => 'Chỉnh Sửa Sinh Viên',
'student' => $student,
'action' => route('sinhvien.update', ['id' => $id]),
'method' => 'PUT',
]);
}
public function update(array $params): void {
$id = (int)$params['id'];
$student = $this->model->find($id);
if (!$student) {
$this->redirect(route('sinhvien.index'));
}
$errors = $this->model->validate($_POST, $id);
if (!empty($errors)) {
$this->withErrors($errors);
setOldInput($_POST);
$this->redirect(route('sinhvien.edit', ['id' => $id]));
}
try {
$data = $this->model->normalizeData($_POST);
$this->model->update($id, $data);
clearOldInput();
flash('success', 'Cập nhật thành công!');
$this->redirect(route('sinhvien.show', ['id' => $id]));
} catch (\Exception $e) {
logMessage('ERROR', $e->getMessage());
flash('error', 'Có lỗi xảy ra.');
$this->back();
}
}
public function destroy(array $params): void {
$id = (int)$params['id'];
try {
$this->model->delete($id);
flash('success', 'Xóa sinh viên thành công!');
} catch (\Exception $e) {
logMessage('ERROR', $e->getMessage());
flash('error', 'Không thể xóa sinh viên này.');
}
$this->redirect(route('sinhvien.index'));
}
public function exportCsv(array $params): void {
$students = $this->model->all();
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="sinhvien_' . date('Y-m-d') . '.csv"');
$output = fopen('php://output', 'w');
// BOM for UTF-8
fprintf($output, chr(0xEF) . chr(0xBB) . chr(0xBF));
// Headers
fputcsv($output, ['MSSV', 'Họ Tên', 'Email', 'Lớp', 'Điểm TB']);
// Data
foreach ($students as $sv) {
fputcsv($output, [
$sv['mssv'],
$sv['ho_ten'],
$sv['email'],
$sv['lop'],
$sv['diem_tb'] ?? '',
]);
}
fclose($output);
exit;
}
}
?>6. Điểm Controller
<?php
// File: app/Controllers/DiemController.php
namespace App\Controllers;
use App\Models\DiemModel;
use App\Models\SinhVienModel;
use App\Models\MonHocModel;
class DiemController extends BaseController {
protected DiemModel $model;
protected SinhVienModel $sinhVienModel;
protected MonHocModel $monHocModel;
public function __construct() {
parent::__construct();
$this->model = new DiemModel();
$this->sinhVienModel = new SinhVienModel();
$this->monHocModel = new MonHocModel();
}
public function index(array $params): string {
$page = (int)($_GET['page'] ?? 1);
$result = $this->model->paginate($page);
return $this->view('diem.index', [
'title' => 'Danh Sách Điểm',
'grades' => $result['items'],
'pagination' => $result,
]);
}
public function nhapDiem(array $params): string {
$students = $this->sinhVienModel->all();
$subjects = $this->monHocModel->all();
return $this->view('diem.form', [
'title' => 'Nhập Điểm',
'students' => $students,
'subjects' => $subjects,
]);
}
public function luuDiem(array $params): void {
$sinhVienId = (int)($_POST['sinh_vien_id'] ?? 0);
$monHocId = (int)($_POST['mon_hoc_id'] ?? 0);
$diemGiuaKy = (float)($_POST['diem_giua_ky'] ?? 0);
$diemCuoiKy = (float)($_POST['diem_cuoi_ky'] ?? 0);
$errors = [];
if (!$sinhVienId) {
$errors['sinh_vien_id'] = 'Vui lòng chọn sinh viên';
}
if (!$monHocId) {
$errors['mon_hoc_id'] = 'Vui lòng chọn môn học';
}
if ($diemGiuaKy < 0 || $diemGiuaKy > 10) {
$errors['diem_giua_ky'] = 'Điểm giữa kỳ phải từ 0-10';
}
if ($diemCuoiKy < 0 || $diemCuoiKy > 10) {
$errors['diem_cuoi_ky'] = 'Điểm cuối kỳ phải từ 0-10';
}
if (!empty($errors)) {
$this->withErrors($errors);
$this->back();
}
try {
// Check nếu đã có điểm
$existing = $this->model->findBySinhVienAndMonHoc($sinhVienId, $monHocId);
if ($existing) {
$this->model->update($existing['id'], [
'diem_giua_ky' => $diemGiuaKy,
'diem_cuoi_ky' => $diemCuoiKy,
]);
} else {
$this->model->create([
'sinh_vien_id' => $sinhVienId,
'mon_hoc_id' => $monHocId,
'diem_giua_ky' => $diemGiuaKy,
'diem_cuoi_ky' => $diemCuoiKy,
]);
}
// Cập nhật điểm TB sinh viên
$grades = $this->model->getBySinhVien($sinhVienId);
$diemTB = 0;
if (!empty($grades)) {
$total = 0;
foreach ($grades as $grade) {
$total += $grade['diem_cuoi_ky'];
}
$diemTB = round($total / count($grades), 2);
}
$this->sinhVienModel->update($sinhVienId, ['diem_tb' => $diemTB]);
flash('success', 'Lưu điểm thành công!');
$this->redirect(route('diem.nhap'));
} catch (\Exception $e) {
logMessage('ERROR', $e->getMessage());
flash('error', 'Có lỗi xảy ra.');
$this->back();
}
}
public function destroy(array $params): void {
$id = (int)$params['id'];
try {
$grade = $this->model->find($id);
if (!$grade) {
flash('error', 'Không tìm thấy điểm');
$this->redirect(route('diem.index'));
}
$this->model->delete($id);
// Cập nhật lại điểm TB
$grades = $this->model->getBySinhVien($grade['sinh_vien_id']);
$diemTB = 0;
if (!empty($grades)) {
$total = 0;
foreach ($grades as $g) {
$total += $g['diem_cuoi_ky'];
}
$diemTB = round($total / count($grades), 2);
}
$this->sinhVienModel->update($grade['sinh_vien_id'], ['diem_tb' => $diemTB]);
flash('success', 'Xóa điểm thành công!');
} catch (\Exception $e) {
logMessage('ERROR', $e->getMessage());
flash('error', 'Không thể xóa điểm này.');
}
$this->redirect(route('diem.index'));
}
}
?>7. Dashboard View
<?php
// File: app/Views/dashboard/index.php
?>
<style>
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 2rem; }
.stat-card { background: white; padding: 1.5rem; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.stat-card h3 { color: #666; font-size: 0.9rem; margin-bottom: 0.5rem; }
.stat-card .number { font-size: 2rem; font-weight: bold; color: #2c3e50; }
.stat-card.excellent .number { color: #2ecc71; }
.stat-card.good .number { color: #3498db; }
.stat-card.failed .number { color: #e74c3c; }
</style>
<div class="card">
<h2>Dashboard</h2>
<!-- Statistics -->
<div class="stats-grid">
<div class="stat-card">
<h3>Tổng Sinh Viên</h3>
<div class="number"><?php echo $stats['total_students']; ?></div>
</div>
<div class="stat-card">
<h3>Tổng Môn Học</h3>
<div class="number"><?php echo $stats['total_subjects']; ?></div>
</div>
<div class="stat-card excellent">
<h3>Xuất Sắc</h3>
<div class="number"><?php echo $stats['excellent']; ?></div>
</div>
<div class="stat-card good">
<h3>Giỏi</h3>
<div class="number"><?php echo $stats['good']; ?></div>
</div>
<div class="stat-card failed">
<h3>Yếu</h3>
<div class="number"><?php echo $stats['failed']; ?></div>
</div>
</div>
<!-- Top students -->
<div style="margin-top: 2rem;">
<h3>Top 10 Sinh Viên Xuất Sắc</h3>
<table style="width: 100%; margin-top: 1rem; border-collapse: collapse;">
<thead>
<tr style="background: #f8f9fa;">
<th style="padding: 0.75rem; text-align: left; border-bottom: 1px solid #ddd;">MSSV</th>
<th style="padding: 0.75rem; text-align: left; border-bottom: 1px solid #ddd;">Họ Tên</th>
<th style="padding: 0.75rem; text-align: left; border-bottom: 1px solid #ddd;">Lớp</th>
<th style="padding: 0.75rem; text-align: left; border-bottom: 1px solid #ddd;">Điểm TB</th>
</tr>
</thead>
<tbody>
<?php foreach ($topStudents as $sv): ?>
<tr style="border-bottom: 1px solid #ddd;">
<td style="padding: 0.75rem;"><?php echo e($sv['mssv']); ?></td>
<td style="padding: 0.75rem;"><?php echo e($sv['ho_ten']); ?></td>
<td style="padding: 0.75rem;"><?php echo e($sv['lop']); ?></td>
<td style="padding: 0.75rem; font-weight: bold; color: #2ecc71;"><?php echo $sv['diem_tb']; ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>8. Navigation
<?php
// File: app/Views/layouts/main.php (cập nhật navigation)
<nav>
<a href="/">Trang chủ</a>
<?php if (isAuthenticated()): ?>
<a href="/dashboard">Dashboard</a>
<a href="/sinhvien">Sinh viên</a>
<a href="/monhoc">Môn học</a>
<a href="/diem">Điểm</a>
<?php if (isAdmin()): ?>
<a href="/admin/dashboard">Quản trị</a>
<a href="/admin/users">Users</a>
<?php endif; ?>
<div style="float: right;">
<a href="/profile"><?php echo e(auth()['name']); ?></a>
<a href="/logout">Đăng xuất</a>
</div>
<?php else: ?>
<div style="float: right;">
<a href="/login">Đăng nhập</a>
<a href="/register">Đăng ký</a>
</div>
<?php endif; ?>
</nav>Tóm tắt
- ✅ Authentication: đăng nhập, đăng ký, phân quyền
- ✅ CRUD đầy đủ: sinh viên, môn học, điểm
- ✅ Dashboard với thống kê
- ✅ Search, filter, phân trang
- ✅ Export CSV
- ✅ Validation, error handling
- ✅ Responsive design
- ✅ Production-ready structure
🎯 Bài tập cuối cùng
- Hoàn thiện project: Implement đầy đủ tất cả controllers, models, views. Test toàn bộ chức năng: CRUD, search, filter, export.
- Deploy: Deploy lên hosting (nếu có). Cấu hình database, .env, .htaccess. Test trên domain thực.
- Portfolio: Viết README, hướng dẫn cài đặt, tính năng. Đưa lên GitHub, thêm vào portfolio. Làm demo video.