Bài 28: Kết nối Model – View – Controller

Bài 23-27 đã xây dựng từng thành phần: Router, Controller, Model, View. Bài này kết nối tất cả lại thành hệ thống MVC hoàn chỉnh — từ request đến response, luồng dữ liệu chạy qua từng layer như thế nào. Bạn sẽ hiểu sâu cách các thành phần tương tác, dependency injection, service layer và cách debug khi có lỗi.

1. Luồng MVC hoàn chỉnh

<?php
/**
 * LUỒNG MVC ĐẦY ĐỦ
 * 
 * 1. HTTP Request → public/index.php
 * 2. Router tìm route khớp
 * 3. Middleware kiểm tra (Auth, CSRF, v.v.)
 * 4. Controller nhận request
 * 5. Controller gọi Model/Service
 * 6. Model truy vấn Database
 * 7. Model trả kết quả cho Controller
 * 8. Controller truyền dữ liệu cho View
 * 9. View render HTML
 * 10. HTTP Response trả về browser
 */

// VÍ DỤ CỤ THỂ: Xem danh sách sinh viên trang 2

// [1] Request
GET /sinhvien?page=2

// [2] Router (config/routes.php)
$router->get('/sinhvien', 'SinhVienController@index');

// [3] Middleware (nếu có)
// Auth middleware kiểm tra đăng nhập

// [4-8] Controller → Model → View
class SinhVienController {
    public function index() {
        // [5] Gọi Model
        $result = $this->model->paginate($_GET['page'] ?? 1);
        
        // [6-7] Model query DB và trả kết quả
        
        // [8] Truyền cho View
        return view('sinhvien.index', [
            'students' => $result['items'],
            'pagination' => $result
        ]);
    }
}

// [9] View render HTML
// [10] Response trả về browser
?>

2. Entry Point — Kết nối tất cả

<?php
// File: public/index.php (HOÀN CHỈNH)

// Khởi tạo ứng dụng
$configs = require __DIR__ . '/../bootstrap/init.php';

// Load router với tất cả routes
$router = require __DIR__ . '/../config/routes.php';

// Lưu router vào global để helper function dùng
$GLOBALS['router'] = $router;

// Lấy request info
$method = $_SERVER['REQUEST_METHOD'];
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

// Chuẩn hóa URI
$basePath = dirname($_SERVER['SCRIPT_NAME']);
if ($basePath !== '/') {
    $uri = substr($uri, strlen($basePath));
}
$uri = '/' . trim($uri, '/');

// Dispatch request
try {
    $response = $router->dispatch($method, $uri);
    
    // Output response
    if (is_string($response)) {
        echo $response;
    } elseif (is_array($response)) {
        header('Content-Type: application/json; charset=utf-8');
        echo json_encode($response, JSON_UNESCAPED_UNICODE);
    }
    
} catch (\RuntimeException $e) {
    // Handle errors
    if ($e->getCode() === 404) {
        http_response_code(404);
        if (config('app.debug')) {
            echo "<h1>404 Not Found</h1><p>{$uri}</p>";
        } else {
            echo view('errors.404', [], null);
        }
    } else {
        if (config('app.debug')) {
            throw $e;
        }
        
        logMessage('ERROR', $e->getMessage());
        http_response_code(500);
        echo view('errors.500', [], null);
    }
    
} catch (\Exception $e) {
    if (config('app.debug')) {
        throw $e;
    }
    
    logMessage('ERROR', $e->getMessage() . "\n" . $e->getTraceAsString());
    http_response_code(500);
    echo view('errors.500', [], null);
}
?>

3. Kết nối Router → Controller

<?php
// File: config/routes.php (ĐẦY ĐỦ)

use App\Core\Router;

$router = new Router();

// ===== WEB ROUTES =====

// Home
$router->get('/', 'HomeController@index')->name('home');

// Sinh viên - Resource routes
$router->get('/sinhvien', 'SinhVienController@index')->name('sinhvien.index');
$router->get('/sinhvien/create', 'SinhVienController@create')->name('sinhvien.create');
$router->post('/sinhvien', 'SinhVienController@store')->name('sinhvien.store');
$router->get('/sinhvien/:id', 'SinhVienController@show')->name('sinhvien.show');
$router->get('/sinhvien/:id/edit', 'SinhVienController@edit')->name('sinhvien.edit');
$router->put('/sinhvien/:id', 'SinhVienController@update')->name('sinhvien.update');
$router->delete('/sinhvien/:id', 'SinhVienController@destroy')->name('sinhvien.destroy');

// Môn học
$router->get('/monhoc', 'MonHocController@index')->name('monhoc.index');
$router->get('/monhoc/create', 'MonHocController@create')->name('monhoc.create');
$router->post('/monhoc', 'MonHocController@store')->name('monhoc.store');

// Điểm
$router->get('/diem', 'DiemController@index')->name('diem.index');
$router->get('/diem/nhap', 'DiemController@nhapDiem')->name('diem.nhap');
$router->post('/diem/luu', 'DiemController@luuDiem')->name('diem.luu');

// ===== API ROUTES =====
$router->group(['prefix' => 'api/v1'], function($r) {
    // Students API
    $r->get('/students', 'Api\StudentController@index');
    $r->get('/students/:id', 'Api\StudentController@show');
    $r->post('/students', 'Api\StudentController@store');
    $r->put('/students/:id', 'Api\StudentController@update');
    $r->delete('/students/:id', 'Api\StudentController@destroy');
    
    // Stats API
    $r->get('/stats/overview', 'Api\StatsController@overview');
    $r->get('/stats/by-class', 'Api\StatsController@byClass');
});

return $router;
?>

4. Kết nối Controller → Model

<?php
// File: app/Controllers/SinhVienController.php (ĐẦY ĐỦ)
namespace App\Controllers;

use App\Models\SinhVienModel;

class SinhVienController extends BaseController {
    protected SinhVienModel $model;
    
    public function __construct() {
        parent::__construct();
        // Khởi tạo Model
        $this->model = new SinhVienModel();
    }
    
    /**
     * Danh sách sinh viên
     */
    public function index(array $params): string {
        $page = (int)($_GET['page'] ?? 1);
        $keyword = trim($_GET['q'] ?? '');
        
        // Gọi Model lấy dữ liệu
        if ($keyword) {
            $result = $this->model->search($keyword, $page, 10);
        } else {
            $result = $this->model->paginate($page, 10);
        }
        
        // Truyền cho View
        return $this->view('sinhvien.index', [
            'title' => 'Danh Sách Sinh Viên',
            'students' => $result['items'],
            'pagination' => $result,
            'keyword' => $keyword,
        ]);
    }
    
    /**
     * Form tạo mới
     */
    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',
        ]);
    }
    
    /**
     * Lưu sinh viên mới
     */
    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);
            setOldInput($_POST);
            $this->redirect(route('sinhvien.create'));
        }
        
        try {
            // Model chuẩn hóa và lưu dữ liệu
            $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. Vui lòng thử lại.');
            $this->back();
        }
    }
    
    /**
     * Chi tiết sinh viên
     */
    public function show(array $params): string {
        $id = (int)$params['id'];
        
        // Model lấy dữ liệu
        $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'], null);
        }
        
        // Lấy thêm điểm số
        $grades = $this->model->getDiem($id);
        
        return $this->view('sinhvien.detail', [
            'title' => 'Chi Tiết Sinh Viên',
            'student' => $student,
            'grades' => $grades,
            'classification' => $this->model->getClassification($student['diem_tb'] ?? 0),
        ]);
    }
    
    /**
     * Form sửa
     */
    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',
        ]);
    }
    
    /**
     * Cập nhật
     */
    public function update(array $params): void {
        $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'));
        }
        
        // Validate với custom logic cho update
        $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();
        }
    }
    
    /**
     * Xóa
     */
    public function destroy(array $params): void {
        $id = (int)$params['id'];
        
        try {
            $student = $this->model->find($id);
            
            if (!$student) {
                flash('error', 'Không tìm thấy sinh viên');
            } else {
                $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 (có thể đang có điểm)');
        }
        
        $this->redirect(route('sinhvien.index'));
    }
}
?>

5. Service Layer — Tách logic phức tạp

<?php
// File: app/Services/SinhVienService.php
namespace App\Services;

use App\Models\SinhVienModel;
use App\Models\DiemModel;

/**
 * Service xử lý business logic phức tạp
 * Controller gọi Service, Service gọi Model
 */
class SinhVienService {
    protected SinhVienModel $sinhVienModel;
    protected DiemModel $diemModel;
    
    public function __construct() {
        $this->sinhVienModel = new SinhVienModel();
        $this->diemModel = new DiemModel();
    }
    
    /**
     * Lấy danh sách với thống kê
     */
    public function getDanhSachVoiThongKe(int $page = 1): array {
        $result = $this->sinhVienModel->paginate($page, 10);
        
        // Thêm thống kê
        $result['stats'] = [
            'total' => $result['total'],
            'excellent' => count($this->sinhVienModel->scopeExcellent()),
            'good' => count($this->sinhVienModel->scopeGood()),
            'failed' => count($this->sinhVienModel->scopeFailed()),
        ];
        
        return $result;
    }
    
    /**
     * Tạo sinh viên mới với validation đầy đủ
     */
    public function taoSinhVien(array $data): array {
        // Chuẩn hóa dữ liệu
        $data = $this->sinhVienModel->normalizeData($data);
        
        // Validate
        $errors = $this->sinhVienModel->validate($data);
        if (!empty($errors)) {
            throw new \InvalidArgumentException(json_encode($errors));
        }
        
        // Tạo sinh viên
        $id = $this->sinhVienModel->create($data);
        
        // Log activity (nếu cần)
        logMessage('INFO', "Tạo sinh viên mới: {$data['mssv']} - {$data['ho_ten']}");
        
        // Gửi email chào mừng (giả lập)
        // $this->sendWelcomeEmail($data['email']);
        
        return $this->sinhVienModel->find($id);
    }
    
    /**
     * Cập nhật điểm trung bình từ các môn
     */
    public function capNhatDiemTrungBinh(int $sinhVienId): void {
        $grades = $this->diemModel->getBySinhVien($sinhVienId);
        
        if (empty($grades)) {
            return;
        }
        
        // Tính điểm TB có trọng số
        $totalCredits = 0;
        $totalPoints = 0;
        
        foreach ($grades as $grade) {
            $finalGrade = $this->diemModel->calculateFinal($grade);
            $credits = $grade['so_tin_chi'] ?? 3;
            
            $totalPoints += $finalGrade * $credits;
            $totalCredits += $credits;
        }
        
        $diemTB = $totalCredits > 0 ? round($totalPoints / $totalCredits, 2) : 0;
        
        // Cập nhật vào sinh viên
        $this->sinhVienModel->update($sinhVienId, ['diem_tb' => $diemTB]);
    }
    
    /**
     * Lấy chi tiết đầy đủ
     */
    public function getChiTietDayDu(int $id): ?array {
        $student = $this->sinhVienModel->find($id);
        if (!$student) return null;
        
        // Lấy điểm
        $grades = $this->diemModel->getBySinhVien($id);
        
        // Tính thống kê
        $stats = [
            'total_subjects' => count($grades),
            'passed' => count(array_filter($grades, fn($g) => $g['diem_cuoi_ky'] >= 5)),
            'failed' => count(array_filter($grades, fn($g) => $g['diem_cuoi_ky'] < 5)),
            'classification' => $this->sinhVienModel->getClassification($student['diem_tb'] ?? 0),
        ];
        
        return [
            'student' => $student,
            'grades' => $grades,
            'stats' => $stats,
        ];
    }
}
?>

6. Controller với Service Layer

<?php
// File: app/Controllers/SinhVienController.php (SỬA LẠI)
namespace App\Controllers;

use App\Services\SinhVienService;

class SinhVienController extends BaseController {
    protected SinhVienService $service;
    
    public function __construct() {
        parent::__construct();
        // Inject Service thay vì Model
        $this->service = new SinhVienService();
    }
    
    public function index(array $params): string {
        $page = (int)($_GET['page'] ?? 1);
        
        // Service xử lý logic phức tạp
        $result = $this->service->getDanhSachVoiThongKe($page);
        
        return $this->view('sinhvien.index', [
            'title' => 'Danh Sách Sinh Viên',
            'students' => $result['items'],
            'pagination' => $result,
            'stats' => $result['stats'],
        ]);
    }
    
    public function store(array $params): void {
        $errors = $this->validate([
            'mssv' => 'required|max:20',
            'ho_ten' => 'required|min:2',
            'email' => 'required|email',
            'lop' => 'required',
        ]);
        
        if (!empty($errors)) {
            $this->withErrors($errors);
            setOldInput($_POST);
            $this->back();
        }
        
        try {
            // Service xử lý tất cả logic
            $student = $this->service->taoSinhVien($_POST);
            
            clearOldInput();
            flash('success', 'Thêm thành công!');
            $this->redirect(route('sinhvien.show', ['id' => $student['id']]));
            
        } catch (\InvalidArgumentException $e) {
            $errors = json_decode($e->getMessage(), true);
            $this->withErrors($errors);
            setOldInput($_POST);
            $this->back();
            
        } 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'];
        
        // Service lấy đầy đủ thông tin
        $data = $this->service->getChiTietDayDu($id);
        
        if (!$data) {
            http_response_code(404);
            return view('errors.404', [], null);
        }
        
        return $this->view('sinhvien.detail', [
            'title' => 'Chi Tiết Sinh Viên',
            'student' => $data['student'],
            'grades' => $data['grades'],
            'stats' => $data['stats'],
        ]);
    }
}
?>

7. Dependency Injection Container

<?php
// File: app/Core/Container.php
namespace App\Core;

/**
 * Simple DI Container
 */
class Container {
    protected array $bindings = [];
    protected array $instances = [];
    
    /**
     * Bind class vào container
     */
    public function bind(string $abstract, $concrete = null): void {
        if ($concrete === null) {
            $concrete = $abstract;
        }
        
        $this->bindings[$abstract] = $concrete;
    }
    
    /**
     * Bind singleton
     */
    public function singleton(string $abstract, $concrete = null): void {
        $this->bind($abstract, $concrete);
        $this->instances[$abstract] = null;
    }
    
    /**
     * Resolve class từ container
     */
    public function make(string $abstract) {
        // Nếu đã có instance (singleton)
        if (isset($this->instances[$abstract]) && $this->instances[$abstract] !== null) {
            return $this->instances[$abstract];
        }
        
        // Lấy concrete class
        $concrete = $this->bindings[$abstract] ?? $abstract;
        
        // Nếu là closure
        if ($concrete instanceof \Closure) {
            $object = $concrete($this);
        } else {
            $object = new $concrete();
        }
        
        // Lưu instance nếu là singleton
        if (array_key_exists($abstract, $this->instances)) {
            $this->instances[$abstract] = $object;
        }
        
        return $object;
    }
}

// Sử dụng:
$container = new Container();

// Bind services
$container->singleton(SinhVienService::class);
$container->singleton(DiemService::class);

// Resolve
$service = $container->make(SinhVienService::class);
?>

8. Debug và Error Handling

<?php
// File: app/Helpers/functions.php (bổ sung)

/**
 * Debug dump
 */
function dd(...$vars): void {
    foreach ($vars as $var) {
        echo '<pre style="background:#1e1e1e;color:#dcdcdc;padding:1rem;border-radius:4px;overflow:auto;">';
        var_dump($var);
        echo '</pre>';
    }
    die(1);
}

/**
 * Dump without die
 */
function dump(...$vars): void {
    foreach ($vars as $var) {
        echo '<pre style="background:#f8f9fa;padding:1rem;border:1px solid #ddd;margin:0.5rem 0;">';
        var_dump($var);
        echo '</pre>';
    }
}

/**
 * Log SQL query
 */
function logQuery(string $sql, array $bindings = []): void {
    if (!config('app.debug')) return;
    
    $query = $sql;
    foreach ($bindings as $binding) {
        $query = preg_replace('/\?/', "'$binding'", $query, 1);
    }
    
    logMessage('SQL', $query);
}
?>

Tóm tắt

  • ✅ Luồng MVC: Request → Router → Controller → Model → View → Response
  • ✅ Controller điều phối, gọi Model/Service lấy dữ liệu
  • ✅ Model truy vấn database, trả kết quả cho Controller
  • ✅ View nhận dữ liệu, render HTML
  • ✅ Service Layer: tách business logic phức tạp khỏi Controller
  • ✅ Dependency Injection: inject dependencies qua constructor
  • ✅ Error handling: try-catch, log, flash message
  • ✅ Debug: dd(), dump(), logQuery()

🎯 Bài tập thực hành

  1. Hoàn thiện CRUD Môn Học: Tạo đầy đủ MonHocController, MonHocModel, MonHocService và views tương tự SinhVien. Thêm relationships: một môn học có nhiều điểm. Test toàn bộ CRUD.
  2. Module Quản Lý Điểm: Xây dựng DiemController với: index() danh sách điểm, nhapDiem() form nhập điểm cho nhiều sinh viên, luuDiem() lưu batch. DiemService tính điểm TB tự động khi lưu.
  3. Dashboard với thống kê: Tạo HomeController với index() hiển thị dashboard: tổng sinh viên, tổng môn học, biểu đồ xếp loại, top 10 sinh viên xuất sắc. Dùng Service tổng hợp dữ liệu từ nhiều Model.

Để lại bình luận

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