Bài 25: Controller – Xử lý logic

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ÀMController 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

  1. 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.
  2. Xây dựng Dashboard Controller: Tạo DashboardController với method index() 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.
  3. API CRUD hoàn chỉnh: Tạo Api\MonHocController vớ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).

Để lại bình luận

Email của bạn sẽ không được hiển thị.