Bài 22 giải thích MVC là gì — bài này bạn sẽ xây dựng thực tế một cấu trúc MVC hoàn chỉnh từ đầu. Không dùng framework mà tự tạo “mini framework” của riêng bạn — điều này giúp bạn hiểu sâu cách Laravel, Symfony hoạt động bên trong. Cấu trúc này sẽ được tái sử dụng ở các bài tiếp theo.
1. Cấu trúc thư mục hoàn chỉnh
qlsinhvien-mvc/
├── public/
│ ├── index.php ← Entry point duy nhất
│ ├── .htaccess ← Apache rewrite rules
│ └── assets/
│ ├── css/
│ └── js/
├── app/
│ ├── Controllers/
│ │ ├── BaseController.php ← Class cha cho mọi controller
│ │ └── SinhVienController.php
│ ├── Models/
│ │ └── SinhVienModel.php
│ ├── Views/
│ │ ├── layouts/
│ │ │ └── main.php ← Layout chung
│ │ └── sinhvien/
│ │ ├── index.php
│ │ ├── form.php
│ │ └── detail.php
│ └── Helpers/
│ └── functions.php ← Hàm tiện ích
├── config/
│ ├── app.php ← Cấu hình ứng dụng
│ ├── database.php ← Cấu hình database
│ └── routes.php ← Định nghĩa routes
├── storage/
│ └── logs/ ← Lưu log files
├── bootstrap/
│ └── init.php ← Khởi tạo ứng dụng
├── vendor/
│ └── autoload.php ← Composer autoload
├── .env ← Environment variables
├── .htaccess ← Root htaccess
└── composer.json💡 Tại sao tách public/? Chỉ thư mục
public/được web server truy cập trực tiếp. Code ứng dụng (app/, config/, storage/) nằm ngoài public — bảo vệ khỏi truy cập trực tiếp qua URL.
2. Thiết lập Composer
{
"name": "your-name/qlsinhvien-mvc",
"description": "Hệ thống quản lý sinh viên MVC",
"type": "project",
"require": {
"php": "^8.0"
},
"autoload": {
"psr-4": {
"App\\Controllers\\": "app/Controllers/",
"App\\Models\\": "app/Models/",
"Config\\": "config/"
},
"files": [
"app/Helpers/functions.php"
]
}
}Chạy lệnh sau để tạo autoloader:
composer install
composer dump-autoload3. Configuration Files
<?php
// File: config/app.php
return [
'name' => 'Quản Lý Sinh Viên',
'env' => $_ENV['APP_ENV'] ?? 'development',
'debug' => ($_ENV['APP_ENV'] ?? 'development') === 'development',
'timezone' => 'Asia/Ho_Chi_Minh',
'url' => $_ENV['APP_URL'] ?? 'http://localhost/qlsinhvien-mvc',
];
// ---
// File: config/database.php
return [
'driver' => 'mysql',
'host' => $_ENV['DB_HOST'] ?? 'localhost',
'port' => $_ENV['DB_PORT'] ?? 3306,
'database' => $_ENV['DB_NAME'] ?? 'qlsinhvien',
'username' => $_ENV['DB_USER'] ?? 'root',
'password' => $_ENV['DB_PASS'] ?? '',
'charset' => 'utf8mb4',
'options' => [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
],
];
// ---
// File: config/routes.php
return [
// Format: 'pattern' => 'Controller@method'
'GET /' => 'SinhVienController@index',
'GET /sinhvien' => 'SinhVienController@index',
'GET /sinhvien/create' => 'SinhVienController@create',
'POST /sinhvien/store' => 'SinhVienController@store',
'GET /sinhvien/:id' => 'SinhVienController@show',
'GET /sinhvien/:id/edit' => 'SinhVienController@edit',
'POST /sinhvien/:id/update' => 'SinhVienController@update',
'POST /sinhvien/:id/delete' => 'SinhVienController@delete',
];
?>4. Bootstrap — Khởi tạo ứng dụng
<?php
// File: bootstrap/init.php
// Định nghĩa constants
define('BASE_PATH', dirname(__DIR__));
define('APP_PATH', BASE_PATH . '/app');
define('CONFIG_PATH', BASE_PATH . '/config');
define('PUBLIC_PATH', BASE_PATH . '/public');
define('STORAGE_PATH', BASE_PATH . '/storage');
// Load .env file (simple implementation)
if (file_exists(BASE_PATH . '/.env')) {
$lines = file(BASE_PATH . '/.env', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
if (strpos(trim($line), '#') === 0) continue;
if (strpos($line, '=') === false) continue;
[$key, $value] = explode('=', $line, 2);
$_ENV[trim($key)] = trim($value);
}
}
// Load Composer autoloader
require BASE_PATH . '/vendor/autoload.php';
// Load app config
$appConfig = require CONFIG_PATH . '/app.php';
// Set timezone
date_default_timezone_set($appConfig['timezone']);
// Error reporting based on environment
if ($appConfig['debug']) {
error_reporting(E_ALL);
ini_set('display_errors', 1);
} else {
error_reporting(0);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
ini_set('error_log', STORAGE_PATH . '/logs/error.log');
}
// Start session
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
// Return configs for use in app
return [
'app' => $appConfig,
'db' => require CONFIG_PATH . '/database.php',
];
?>5. Helper Functions
<?php
// File: app/Helpers/functions.php
/**
* Render view file
*/
function view(string $view, array $data = []): string {
extract($data);
$viewFile = APP_PATH . '/Views/' . str_replace('.', '/', $view) . '.php';
if (!file_exists($viewFile)) {
throw new RuntimeException("View not found: {$view}");
}
ob_start();
require $viewFile;
return ob_get_clean();
}
/**
* Redirect to URL
*/
function redirect(string $path): void {
$baseUrl = rtrim(config('app.url'), '/');
header("Location: {$baseUrl}{$path}");
exit;
}
/**
* Get config value
*/
function config(string $key, $default = null) {
static $configs = null;
if ($configs === null) {
$configs = [
'app' => require CONFIG_PATH . '/app.php',
'db' => require CONFIG_PATH . '/database.php',
];
}
$keys = explode('.', $key);
$value = $configs;
foreach ($keys as $k) {
if (!isset($value[$k])) return $default;
$value = $value[$k];
}
return $value;
}
/**
* Get/set flash message
*/
function flash(?string $key = null, $value = null) {
if ($key === null) {
return $_SESSION['_flash'] ?? [];
}
if ($value === null) {
$val = $_SESSION['_flash'][$key] ?? null;
unset($_SESSION['_flash'][$key]);
return $val;
}
$_SESSION['_flash'][$key] = $value;
}
/**
* HTML escape
*/
function e(?string $value): string {
return htmlspecialchars($value ?? '', ENT_QUOTES, 'UTF-8');
}
/**
* Get old input value
*/
function old(string $key, $default = '') {
return $_SESSION['_old'][$key] ?? $default;
}
/**
* Set old input
*/
function setOldInput(array $data): void {
$_SESSION['_old'] = $data;
}
/**
* Clear old input
*/
function clearOldInput(): void {
unset($_SESSION['_old']);
}
/**
* Database connection (singleton)
*/
function db(): PDO {
static $pdo = null;
if ($pdo === null) {
$config = config('db');
$dsn = sprintf(
"%s:host=%s;port=%s;dbname=%s;charset=%s",
$config['driver'],
$config['host'],
$config['port'],
$config['database'],
$config['charset']
);
try {
$pdo = new PDO($dsn, $config['username'], $config['password'], $config['options']);
} catch (PDOException $e) {
if (config('app.debug')) {
die("Database connection failed: " . $e->getMessage());
}
die("Database connection failed");
}
}
return $pdo;
}
/**
* Log message to file
*/
function logMessage(string $level, string $message): void {
$logDir = STORAGE_PATH . '/logs';
if (!is_dir($logDir)) {
mkdir($logDir, 0755, true);
}
$logFile = $logDir . '/' . date('Y-m-d') . '.log';
$timestamp = date('Y-m-d H:i:s');
$line = "[{$timestamp}] [{$level}] {$message}" . PHP_EOL;
file_put_contents($logFile, $line, FILE_APPEND | LOCK_EX);
}
?>6. Base Controller
<?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
*/
protected function view(string $view, array $data = []): string {
return view($view, array_merge($this->data, $data));
}
/**
* Redirect
*/
protected function redirect(string $path): void {
redirect($path);
}
/**
* Return JSON response
*/
protected function json(array $data, int $status = 200): void {
http_response_code($status);
header('Content-Type: application/json');
echo json_encode($data, JSON_UNESCAPED_UNICODE);
exit;
}
/**
* 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] = ucfirst($field) . " không được để trống";
break;
}
// Email
if ($rule === 'email' && $value && !filter_var($value, FILTER_VALIDATE_EMAIL)) {
$errors[$field] = ucfirst($field) . " không đúng định dạng email";
break;
}
// Numeric
if ($rule === 'numeric' && $value && !is_numeric($value)) {
$errors[$field] = ucfirst($field) . " phải là số";
break;
}
// Min length
if (preg_match('/^min:(\d+)$/', $rule, $matches)) {
$min = (int)$matches[1];
if (strlen($value ?? '') < $min) {
$errors[$field] = ucfirst($field) . " phải có ít nhất {$min} ký tự";
break;
}
}
// Max length
if (preg_match('/^max:(\d+)$/', $rule, $matches)) {
$max = (int)$matches[1];
if (strlen($value ?? '') > $max) {
$errors[$field] = ucfirst($field) . " không được vượt quá {$max} ký tự";
break;
}
}
}
}
return $errors;
}
/**
* Set validation errors to 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;
}
}
?>7. Entry Point — public/index.php
<?php
// File: public/index.php
// Initialize application
$configs = require __DIR__ . '/../bootstrap/init.php';
// Load routes
$routes = require CONFIG_PATH . '/routes.php';
// Get request method and URI
$method = $_SERVER['REQUEST_METHOD'];
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
// Remove script name from URI if present
$scriptName = dirname($_SERVER['SCRIPT_NAME']);
if ($scriptName !== '/') {
$uri = substr($uri, strlen($scriptName));
}
$uri = '/' . trim($uri, '/');
// Find matching route
$handler = null;
$params = [];
foreach ($routes as $route => $target) {
// Split method and pattern
[$routeMethod, $pattern] = explode(' ', $route, 2);
// Check method
if ($routeMethod !== $method) continue;
// Convert :param to regex
$regex = preg_replace('/:(\w+)/', '(?P<$1>[^/]+)', $pattern);
$regex = '#^' . $regex . '$#';
// Match URI
if (preg_match($regex, $uri, $matches)) {
$handler = $target;
// Extract named parameters
foreach ($matches as $key => $value) {
if (!is_int($key)) {
$params[$key] = $value;
}
}
break;
}
}
// 404 if no route found
if (!$handler) {
http_response_code(404);
echo view('errors.404');
exit;
}
// Parse handler
[$controllerName, $action] = explode('@', $handler);
$controllerClass = "App\\Controllers\\{$controllerName}";
// Check controller exists
if (!class_exists($controllerClass)) {
if (config('app.debug')) {
die("Controller not found: {$controllerClass}");
}
http_response_code(500);
die("Internal Server Error");
}
// Instantiate controller
$controller = new $controllerClass();
// Check method exists
if (!method_exists($controller, $action)) {
if (config('app.debug')) {
die("Method not found: {$controllerClass}::{$action}");
}
http_response_code(500);
die("Internal Server Error");
}
// Call controller method
try {
$response = call_user_func_array([$controller, $action], $params);
// Output response
if (is_string($response)) {
echo $response;
} elseif (is_array($response)) {
header('Content-Type: application/json');
echo json_encode($response, JSON_UNESCAPED_UNICODE);
}
} catch (Exception $e) {
if (config('app.debug')) {
throw $e;
}
logMessage('ERROR', $e->getMessage());
http_response_code(500);
echo view('errors.500');
}
?>8. Apache .htaccess
# File: public/.htaccess
<IfModule mod_rewrite.c>
RewriteEngine On
# Redirect to HTTPS (optional)
# RewriteCond %{HTTPS} off
# RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
# Handle front controller
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [L]
</IfModule>
# File: .htaccess (root)
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule ^(.*)$ public/$1 [L]
</IfModule>9. Model Example
<?php
// File: app/Models/SinhVienModel.php
namespace App\Models;
use PDO;
class SinhVienModel {
protected PDO $db;
protected string $table = 'sinhvien';
public function __construct() {
$this->db = db();
}
/**
* Get all with pagination
*/
public function paginate(int $page = 1, int $perPage = 10): array {
$offset = ($page - 1) * $perPage;
$stmt = $this->db->prepare(
"SELECT * FROM {$this->table} ORDER BY ho_ten LIMIT ? OFFSET ?"
);
$stmt->execute([$perPage, $offset]);
$items = $stmt->fetchAll();
$total = $this->count();
return [
'items' => $items,
'total' => $total,
'per_page' => $perPage,
'current_page' => $page,
'last_page' => ceil($total / $perPage),
];
}
/**
* Count total records
*/
public function count(): int {
return (int)$this->db->query("SELECT COUNT(*) FROM {$this->table}")->fetchColumn();
}
/**
* Find by ID
*/
public function find(int $id): ?array {
$stmt = $this->db->prepare("SELECT * FROM {$this->table} WHERE id = ?");
$stmt->execute([$id]);
$result = $stmt->fetch();
return $result ?: null;
}
/**
* Create new record
*/
public function create(array $data): int {
$fields = ['mssv', 'ho_ten', 'email', 'lop', 'ghi_chu'];
$placeholders = array_fill(0, count($fields), '?');
$sql = sprintf(
"INSERT INTO {$this->table} (%s) VALUES (%s)",
implode(', ', $fields),
implode(', ', $placeholders)
);
$values = array_map(fn($f) => $data[$f] ?? null, $fields);
$stmt = $this->db->prepare($sql);
$stmt->execute($values);
return (int)$this->db->lastInsertId();
}
/**
* Update record
*/
public function update(int $id, array $data): bool {
$fields = ['mssv', 'ho_ten', 'email', 'lop', 'ghi_chu'];
$sets = array_map(fn($f) => "$f = ?", $fields);
$sql = sprintf(
"UPDATE {$this->table} SET %s WHERE id = ?",
implode(', ', $sets)
);
$values = array_map(fn($f) => $data[$f] ?? null, $fields);
$values[] = $id;
$stmt = $this->db->prepare($sql);
return $stmt->execute($values);
}
/**
* Delete record
*/
public function delete(int $id): bool {
$stmt = $this->db->prepare("DELETE FROM {$this->table} WHERE id = ?");
return $stmt->execute([$id]);
}
/**
* Search
*/
public function search(string $keyword, int $page = 1, int $perPage = 10): array {
$offset = ($page - 1) * $perPage;
$like = "%{$keyword}%";
$stmt = $this->db->prepare(
"SELECT * FROM {$this->table}
WHERE mssv LIKE ? OR ho_ten LIKE ? OR email LIKE ?
ORDER BY ho_ten LIMIT ? OFFSET ?"
);
$stmt->execute([$like, $like, $like, $perPage, $offset]);
$items = $stmt->fetchAll();
$stmt = $this->db->prepare(
"SELECT COUNT(*) FROM {$this->table}
WHERE mssv LIKE ? OR ho_ten LIKE ? OR email LIKE ?"
);
$stmt->execute([$like, $like, $like]);
$total = (int)$stmt->fetchColumn();
return [
'items' => $items,
'total' => $total,
'per_page' => $perPage,
'current_page' => $page,
'last_page' => ceil($total / $perPage),
'keyword' => $keyword,
];
}
}
?>10. Controller Example
<?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();
}
/**
* List all students
*/
public function index(): string {
$page = (int)($_GET['page'] ?? 1);
$keyword = trim($_GET['q'] ?? '');
if ($keyword) {
$result = $this->model->search($keyword, $page);
} else {
$result = $this->model->paginate($page);
}
return $this->view('sinhvien.index', [
'students' => $result['items'],
'pagination' => $result,
'keyword' => $keyword,
'success' => flash('success'),
]);
}
/**
* Show create form
*/
public function create(): string {
return $this->view('sinhvien.form', [
'title' => 'Thêm Sinh Viên Mới',
'errors' => $this->getErrors(),
]);
}
/**
* Store new student
*/
public function store(): void {
$errors = $this->validate([
'mssv' => 'required|max:20',
'ho_ten' => 'required|max:100',
'email' => 'required|email',
'lop' => 'required|max:20',
]);
if (!empty($errors)) {
$this->withErrors($errors);
setOldInput($_POST);
$this->redirect('/sinhvien/create');
}
$id = $this->model->create($_POST);
clearOldInput();
flash('success', 'Thêm sinh viên thành công!');
$this->redirect("/sinhvien/{$id}");
}
/**
* Show student detail
*/
public function show(array $params): string {
$id = (int)$params['id'];
$student = $this->model->find($id);
if (!$student) {
http_response_code(404);
return $this->view('errors.404');
}
return $this->view('sinhvien.detail', [
'student' => $student,
'success' => flash('success'),
]);
}
/**
* Show edit form
*/
public function edit(array $params): string {
$id = (int)$params['id'];
$student = $this->model->find($id);
if (!$student) {
http_response_code(404);
return $this->view('errors.404');
}
return $this->view('sinhvien.form', [
'title' => 'Chỉnh Sửa Sinh Viên',
'student' => $student,
'errors' => $this->getErrors(),
]);
}
/**
* Update student
*/
public function update(array $params): void {
$id = (int)$params['id'];
$errors = $this->validate([
'mssv' => 'required|max:20',
'ho_ten' => 'required|max:100',
'email' => 'required|email',
'lop' => 'required|max:20',
]);
if (!empty($errors)) {
$this->withErrors($errors);
setOldInput($_POST);
$this->redirect("/sinhvien/{$id}/edit");
}
$this->model->update($id, $_POST);
clearOldInput();
flash('success', 'Cập nhật thành công!');
$this->redirect("/sinhvien/{$id}");
}
/**
* Delete student
*/
public function delete(array $params): void {
$id = (int)$params['id'];
$this->model->delete($id);
flash('success', 'Xóa sinh viên thành công!');
$this->redirect('/sinhvien');
}
}
?>Tóm tắt
- ✅ Cấu trúc thư mục chuẩn: tách public/, app/, config/, storage/
- ✅ Composer autoload PSR-4 cho namespace
- ✅ Bootstrap init.php khởi tạo ứng dụng
- ✅ Helper functions: view(), redirect(), config(), flash(), db()
- ✅ BaseController: class cha chung với validate(), render(), json()
- ✅ Entry point duy nhất: public/index.php với router đơn giản
- ✅ .htaccess redirect tất cả request vào index.php
- ✅ Environment variables qua .env file
- ✅ Error handling khác nhau giữa development và production
🎯 Bài tập thực hành
-
Thiết lập dự án hoàn chỉnh: Tạo toàn bộ cấu trúc thư mục như trên. Cấu hình database, chạy
composer install. Tạo bảngsinhvientrong MySQL. Test CRUD đầy đủ: danh sách, thêm, sửa, xóa, tìm kiếm. -
Thêm module Môn Học: Tạo
MonHocController,MonHocModelvà views tương ứng. Thêm routes vào config/routes.php. Implement đầy đủ CRUD. Kiểm tra hai module hoạt động độc lập. -
Error pages: Tạo view
app/Views/errors/404.phpvà500.php. Test bằng cách truy cập route không tồn tại và cố tình gây lỗi trong controller. Kiểm tra error log được ghi vàostorage/logs/.