Bài 24 xây dựng Router điều hướng request đến controller. Bài này đi sâu vào Controller — thành phần trung tâm của MVC, nhận request từ router, gọi model lấy dữ liệu, truyền cho view để render. Controller tốt phải gọn, rõ ràng, dễ test — không chứa business logic phức tạp, không truy cập database trực tiếp.
1. Vai trò của Controller trong MVC
| Controller LÀM | Controller KHÔNG LÀM |
|---|---|
| ✅ Nhận request từ router | ❌ Viết SQL trực tiếp |
| ✅ Validate input (hoặc delegate cho Form Request) | ❌ Chứa business logic phức tạp |
| ✅ Gọi Model/Service để xử lý dữ liệu | ❌ Render HTML (việc của View) |
| ✅ Truyền dữ liệu cho View | ❌ Xử lý file upload chi tiết |
| ✅ Redirect, flash message | ❌ Tính toán business logic |
| ✅ Handle exceptions từ Model | ❌ Kết nối database trực tiếp |
💡 Nguyên tắc “Thin Controller, Fat Model”: Controller chỉ điều phối — logic nghiệp vụ (tính điểm, kiểm tra điều kiện, xử lý phức tạp) nằm trong Model hoặc Service. Controller mỏng (50-100 dòng/method) dễ đọc, dễ test hơn controller béo (500+ dòng).
2. BaseController — Class cha chung
<?php
// File: app/Controllers/BaseController.php
namespace App\Controllers;
use PDO;
abstract class BaseController {
protected PDO $db;
protected array $data = [];
public function __construct() {
$this->db = db();
}
/**
* Render view với layout
*/
protected function view(string $view, array $data = []): string {
// Merge global data
$data = array_merge($this->data, $data);
// Add common data
$data['errors'] = $this->getErrors();
$data['old'] = $_SESSION['_old'] ?? [];
return view($view, $data);
}
/**
* Render view với layout tùy chỉnh
*/
protected function viewWithLayout(string $view, array $data = [], string $layout = 'layouts.main'): string {
$content = view($view, $data);
return view($layout, array_merge($data, ['content' => $content]));
}
/**
* Redirect
*/
protected function redirect(string $path): void {
redirect($path);
}
/**
* Redirect back
*/
protected function back(): void {
$referer = $_SERVER['HTTP_REFERER'] ?? '/';
redirect($referer);
}
/**
* Return JSON response
*/
protected function json(array $data, int $status = 200): void {
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
exit;
}
/**
* Return success JSON
*/
protected function success($data = null, string $message = 'Success', int $status = 200): void {
$this->json([
'success' => true,
'message' => $message,
'data' => $data,
], $status);
}
/**
* Return error JSON
*/
protected function error(string $message, $errors = null, int $status = 400): void {
$this->json([
'success' => false,
'message' => $message,
'errors' => $errors,
], $status);
}
/**
* Validate input
*/
protected function validate(array $rules, array $data = null): array {
$data = $data ?? $_POST;
$errors = [];
foreach ($rules as $field => $ruleString) {
$value = $data[$field] ?? null;
$ruleArray = explode('|', $ruleString);
foreach ($ruleArray as $rule) {
// Required
if ($rule === 'required' && empty($value)) {
$errors[$field] = $this->getFieldName($field) . " không được để trống";
break;
}
// Email
if ($rule === 'email' && $value && !filter_var($value, FILTER_VALIDATE_EMAIL)) {
$errors[$field] = $this->getFieldName($field) . " không đúng định dạng email";
break;
}
// Numeric
if ($rule === 'numeric' && $value && !is_numeric($value)) {
$errors[$field] = $this->getFieldName($field) . " phải là số";
break;
}
// Min
if (preg_match('/^min:(\d+)$/', $rule, $m)) {
$min = (int)$m[1];
if (is_numeric($value) && $value < $min) {
$errors[$field] = $this->getFieldName($field) . " phải >= {$min}";
break;
} elseif (strlen($value ?? '') < $min) {
$errors[$field] = $this->getFieldName($field) . " phải có ít nhất {$min} ký tự";
break;
}
}
// Max
if (preg_match('/^max:(\d+)$/', $rule, $m)) {
$max = (int)$m[1];
if (is_numeric($value) && $value > $max) {
$errors[$field] = $this->getFieldName($field) . " phải <= {$max}";
break;
} elseif (strlen($value ?? '') > $max) {
$errors[$field] = $this->getFieldName($field) . " không được vượt quá {$max} ký tự";
break;
}
}
// In (enum)
if (preg_match('/^in:(.+)$/', $rule, $m)) {
$allowed = explode(',', $m[1]);
if ($value && !in_array($value, $allowed)) {
$errors[$field] = $this->getFieldName($field) . " không hợp lệ";
break;
}
}
// Unique (database check)
if (preg_match('/^unique:(\w+),?(\w+)?$/', $rule, $m)) {
$table = $m[1];
$column = $m[2] ?? $field;
if ($value && $this->existsInDatabase($table, $column, $value)) {
$errors[$field] = $this->getFieldName($field) . " đã tồn tại";
break;
}
}
}
}
return $errors;
}
/**
* Get friendly field name
*/
protected function getFieldName(string $field): string {
$names = [
'mssv' => 'MSSV',
'ho_ten' => 'Họ tên',
'email' => 'Email',
'lop' => 'Lớp',
'diem_tb' => 'Điểm TB',
'password' => 'Mật khẩu',
'password_confirm' => 'Xác nhận mật khẩu',
];
return $names[$field] ?? ucfirst(str_replace('_', ' ', $field));
}
/**
* Check if value exists in database
*/
protected function existsInDatabase(string $table, string $column, $value): bool {
$stmt = $this->db->prepare("SELECT COUNT(*) FROM {$table} WHERE {$column} = ?");
$stmt->execute([$value]);
return (int)$stmt->fetchColumn() > 0;
}
/**
* Store validation errors in session
*/
protected function withErrors(array $errors): void {
$_SESSION['_errors'] = $errors;
}
/**
* Get validation errors from session
*/
protected function getErrors(): array {
$errors = $_SESSION['_errors'] ?? [];
unset($_SESSION['_errors']);
return $errors;
}
/**
* Flash message
*/
protected function flash(string $key, $value): void {
flash($key, $value);
}
/**
* Set old input
*/
protected function withInput(array $input): void {
setOldInput($input);
}
/**
* Authorize user
*/
protected function authorize(bool $condition, string $message = 'Unauthorized', int $code = 403): void {
if (!$condition) {
if ($this->isApiRequest()) {
$this->error($message, null, $code);
}
http_response_code($code);
echo view("errors.{$code}", ['message' => $message]);
exit;
}
}
/**
* Check if request is API
*/
protected function isApiRequest(): bool {
return str_starts_with($_SERVER['REQUEST_URI'], '/api/');
}
/**
* Get authenticated user (placeholder)
*/
protected function user(): ?array {
if (!isset($_SESSION['user_id'])) {
return null;
}
// Load user from database
$stmt = $this->db->prepare("SELECT * FROM users WHERE id = ?");
$stmt->execute([$_SESSION['user_id']]);
return $stmt->fetch() ?: null;
}
}
?>3. Resource Controller — RESTful CRUD
<?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();
}
/**
* Display a listing of the resource
* GET /sinhvien
*/
public function index(array $params): string {
$page = (int)($_GET['page'] ?? 1);
$keyword = trim($_GET['q'] ?? '');
if ($keyword) {
$result = $this->model->search($keyword, $page, 10);
} else {
$result = $this->model->paginate($page, 10);
}
return $this->view('sinhvien.index', [
'students' => $result['items'],
'pagination' => $result,
'keyword' => $keyword,
'success' => flash('success'),
]);
}
/**
* Show the form for creating a new resource
* GET /sinhvien/create
*/
public function create(array $params): string {
return $this->view('sinhvien.form', [
'title' => 'Thêm Sinh Viên Mới',
'action' => route('sinhvien.store'),
'method' => 'POST',
]);
}
/**
* Store a newly created resource in storage
* POST /sinhvien
*/
public function store(array $params): void {
// Validate
$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',
'diem_tb' => 'numeric|min:0|max:10',
]);
if (!empty($errors)) {
$this->withErrors($errors);
$this->withInput($_POST);
$this->back();
}
// Create
try {
$id = $this->model->create($_POST);
clearOldInput();
$this->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());
$this->flash('error', 'Có lỗi xảy ra. Vui lòng thử lại.');
$this->back();
}
}
/**
* Display the specified resource
* GET /sinhvien/:id
*/
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', ['message' => 'Không tìm thấy sinh viên']);
}
return $this->view('sinhvien.detail', [
'student' => $student,
'success' => flash('success'),
]);
}
/**
* Show the form for editing the specified resource
* GET /sinhvien/:id/edit
*/
public function edit(array $params): string {
$id = (int)$params['id'];
$student = $this->model->find($id);
if (!$student) {
$this->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',
]);
}
/**
* Update the specified resource in storage
* PUT /sinhvien/:id
*/
public function update(array $params): void {
$id = (int)$params['id'];
$student = $this->model->find($id);
if (!$student) {
$this->flash('error', 'Không tìm thấy sinh viên');
$this->redirect(route('sinhvien.index'));
}
// Validate (skip unique check for current record)
$errors = $this->validate([
'mssv' => 'required|max:20',
'ho_ten' => 'required|min:2|max:100',
'email' => 'required|email',
'lop' => 'required|max:20',
'diem_tb' => 'numeric|min:0|max:10',
]);
// Manual check for unique (exclude current ID)
if ($_POST['mssv'] !== $student['mssv']) {
$stmt = $this->db->prepare("SELECT COUNT(*) FROM sinhvien WHERE mssv = ? AND id != ?");
$stmt->execute([$_POST['mssv'], $id]);
if ((int)$stmt->fetchColumn() > 0) {
$errors['mssv'] = 'MSSV đã tồn tại';
}
}
if ($_POST['email'] !== $student['email']) {
$stmt = $this->db->prepare("SELECT COUNT(*) FROM sinhvien WHERE email = ? AND id != ?");
$stmt->execute([$_POST['email'], $id]);
if ((int)$stmt->fetchColumn() > 0) {
$errors['email'] = 'Email đã tồn tại';
}
}
if (!empty($errors)) {
$this->withErrors($errors);
$this->withInput($_POST);
$this->back();
}
// Update
try {
$this->model->update($id, $_POST);
clearOldInput();
$this->flash('success', 'Cập nhật thành công!');
$this->redirect(route('sinhvien.show', ['id' => $id]));
} catch (\Exception $e) {
logMessage('ERROR', $e->getMessage());
$this->flash('error', 'Có lỗi xảy ra. Vui lòng thử lại.');
$this->back();
}
}
/**
* Remove the specified resource from storage
* DELETE /sinhvien/:id
*/
public function destroy(array $params): void {
$id = (int)$params['id'];
try {
$student = $this->model->find($id);
if (!$student) {
$this->flash('error', 'Không tìm thấy sinh viên');
} else {
$this->model->delete($id);
$this->flash('success', 'Xóa sinh viên thành công!');
}
} catch (\Exception $e) {
logMessage('ERROR', $e->getMessage());
$this->flash('error', 'Không thể xóa sinh viên này');
}
$this->redirect(route('sinhvien.index'));
}
}
?>4. API Controller
<?php
// File: app/Controllers/Api/SinhVienController.php
namespace App\Controllers\Api;
use App\Controllers\BaseController;
use App\Models\SinhVienModel;
class SinhVienController extends BaseController {
protected SinhVienModel $model;
public function __construct() {
parent::__construct();
$this->model = new SinhVienModel();
}
/**
* GET /api/v1/students
*/
public function index(array $params): void {
$page = (int)($_GET['page'] ?? 1);
$perPage = (int)($_GET['per_page'] ?? 15);
$keyword = trim($_GET['q'] ?? '');
if ($keyword) {
$result = $this->model->search($keyword, $page, $perPage);
} else {
$result = $this->model->paginate($page, $perPage);
}
$this->success($result, 'Lấy danh sách thành công');
}
/**
* POST /api/v1/students
*/
public function store(array $params): void {
// Get JSON input
$input = json_decode(file_get_contents('php://input'), true);
// Validate
$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',
], $input);
if (!empty($errors)) {
$this->error('Dữ liệu không hợp lệ', $errors, 422);
}
try {
$id = $this->model->create($input);
$student = $this->model->find($id);
$this->success($student, 'Tạo sinh viên thành công', 201);
} catch (\Exception $e) {
logMessage('ERROR', $e->getMessage());
$this->error('Có lỗi xảy ra', null, 500);
}
}
/**
* GET /api/v1/students/:id
*/
public function show(array $params): void {
$id = (int)$params['id'];
$student = $this->model->find($id);
if (!$student) {
$this->error('Không tìm thấy sinh viên', null, 404);
}
$this->success($student);
}
/**
* PUT /api/v1/students/:id
*/
public function update(array $params): void {
$id = (int)$params['id'];
$input = json_decode(file_get_contents('php://input'), true);
$student = $this->model->find($id);
if (!$student) {
$this->error('Không tìm thấy sinh viên', null, 404);
}
// Validate
$errors = $this->validate([
'mssv' => 'required|max:20',
'ho_ten' => 'required|min:2|max:100',
'email' => 'required|email',
'lop' => 'required|max:20',
], $input);
if (!empty($errors)) {
$this->error('Dữ liệu không hợp lệ', $errors, 422);
}
try {
$this->model->update($id, $input);
$student = $this->model->find($id);
$this->success($student, 'Cập nhật thành công');
} catch (\Exception $e) {
logMessage('ERROR', $e->getMessage());
$this->error('Có lỗi xảy ra', null, 500);
}
}
/**
* DELETE /api/v1/students/:id
*/
public function destroy(array $params): void {
$id = (int)$params['id'];
$student = $this->model->find($id);
if (!$student) {
$this->error('Không tìm thấy sinh viên', null, 404);
}
try {
$this->model->delete($id);
$this->success(null, 'Xóa thành công');
} catch (\Exception $e) {
logMessage('ERROR', $e->getMessage());
$this->error('Có lỗi xảy ra', null, 500);
}
}
}
?>5. Single Action Controller
<?php
// File: app/Controllers/ExportSinhVienController.php
namespace App\Controllers;
use App\Models\SinhVienModel;
/**
* Single action controller - một controller chỉ làm một việc
*/
class ExportSinhVienController extends BaseController {
public function __invoke(array $params): void {
$model = new SinhVienModel();
$students = $model->all();
// Set headers for CSV download
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));
// Header row
fputcsv($output, ['MSSV', 'Họ Tên', 'Email', 'Lớp', 'Điểm TB']);
// Data rows
foreach ($students as $sv) {
fputcsv($output, [
$sv['mssv'],
$sv['ho_ten'],
$sv['email'],
$sv['lop'],
$sv['diem_tb'] ?? '',
]);
}
fclose($output);
exit;
}
}
// Route:
// $router->get('/sinhvien/export', ExportSinhVienController::class);
?>6. Dependency Injection trong Controller
<?php
// File: app/Controllers/SinhVienController.php (cải tiến)
namespace App\Controllers;
use App\Models\SinhVienModel;
use App\Services\SinhVienService;
class SinhVienController extends BaseController {
// Inject service thay vì model
public function __construct(
protected SinhVienService $service
) {
parent::__construct();
}
public function index(array $params): string {
$page = (int)($_GET['page'] ?? 1);
$result = $this->service->getPaginated($page);
return $this->view('sinhvien.index', $result);
}
public function store(array $params): void {
$errors = $this->validate([
'mssv' => 'required|unique:sinhvien',
'ho_ten' => 'required|min:2',
'email' => 'required|email|unique:sinhvien',
]);
if (!empty($errors)) {
$this->withErrors($errors);
$this->back();
}
// Service xử lý logic phức tạp
$student = $this->service->createStudent($_POST);
$this->flash('success', 'Thêm thành công!');
$this->redirect(route('sinhvien.show', ['id' => $student['id']]));
}
}
// ---
// File: app/Services/SinhVienService.php
namespace App\Services;
use App\Models\SinhVienModel;
class SinhVienService {
public function __construct(
protected SinhVienModel $model
) {}
public function getPaginated(int $page, int $perPage = 10): array {
return $this->model->paginate($page, $perPage);
}
public function createStudent(array $data): array {
// Business logic: chuẩn hóa dữ liệu
$data['mssv'] = strtoupper(trim($data['mssv']));
$data['ho_ten'] = $this->normalizeName($data['ho_ten']);
$data['email'] = strtolower(trim($data['email']));
// Tạo student
$id = $this->model->create($data);
// Business logic: gửi email chào mừng (nếu cần)
// $this->sendWelcomeEmail($data['email']);
return $this->model->find($id);
}
protected function normalizeName(string $name): string {
// Chuẩn hóa: loại bỏ khoảng trắng thừa, viết hoa đầu từ
$name = trim(preg_replace('/\s+/', ' ', $name));
return mb_convert_case($name, MB_CASE_TITLE, 'UTF-8');
}
}
?>7. Form Request Validation
<?php
// File: app/Requests/SinhVienRequest.php
namespace App\Requests;
class SinhVienRequest {
protected array $rules = [
'mssv' => 'required|max:20|unique:sinhvien',
'ho_ten' => 'required|min:2|max:100',
'email' => 'required|email|unique:sinhvien',
'lop' => 'required|max:20',
'diem_tb' => 'numeric|min:0|max:10',
];
protected array $messages = [
'mssv.required' => 'MSSV không được để trống',
'mssv.unique' => 'MSSV đã tồn tại trong hệ thống',
'ho_ten.required' => 'Họ tên không được để trống',
'ho_ten.min' => 'Họ tên phải có ít nhất 2 ký tự',
'email.required' => 'Email không được để trống',
'email.email' => 'Email không đúng định dạng',
'email.unique' => 'Email đã tồn tại',
];
public function validate(array $data): array {
// Logic validation tái sử dụng
$errors = [];
// Implement validation based on rules...
return $errors;
}
public function getRules(): array {
return $this->rules;
}
}
// Sử dụng trong controller:
public function store(array $params): void {
$request = new SinhVienRequest();
$errors = $request->validate($_POST);
if (!empty($errors)) {
$this->withErrors($errors);
$this->back();
}
// Continue...
}
?>Tóm tắt
- ✅ Controller điều phối — không chứa business logic phức tạp
- ✅ BaseController cung cấp methods chung: view(), redirect(), json(), validate()
- ✅ Resource Controller: 7 methods chuẩn RESTful (index, create, store, show, edit, update, destroy)
- ✅ API Controller trả về JSON với success()/error()
- ✅ Single Action Controller: một controller chỉ làm một việc duy nhất
- ✅ Dependency Injection: inject Service/Model vào constructor
- ✅ Form Request: tách validation logic ra class riêng
- ✅ “Thin Controller, Fat Model”: logic trong Model/Service, không trong Controller
- ✅ Exception handling: try-catch, log error, flash message
🎯 Bài tập thực hành
-
Refactor SinhVienController: Tách business logic từ controller vào
SinhVienService. Service xử lý: chuẩn hóa dữ liệu, tính toán xếp loại, gửi thông báo. Controller chỉ gọi service và render view. So sánh code trước/sau refactor. -
Xây dựng Dashboard Controller: Tạo
DashboardControllervới methodindex()hiển thị: tổng sinh viên, tổng môn học, biểu đồ xếp loại (data từ multiple models). Inject các service cần thiết qua constructor. -
API CRUD hoàn chỉnh: Tạo
Api\MonHocControllervới đầy đủ 5 methods RESTful. Test bằng Postman/curl: tạo, đọc, cập nhật, xóa môn học. Đảm bảo response JSON đúng format, HTTP status code chính xác (200, 201, 404, 422, 500).