Bài 23: Xây dựng cấu trúc thư mục MVC từ đầu

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-autoload

3. 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

  1. 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ảng sinhvien trong MySQL. Test CRUD đầy đủ: danh sách, thêm, sửa, xóa, tìm kiếm.
  2. Thêm module Môn Học: Tạo MonHocController, MonHocModel và 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.
  3. Error pages: Tạo view app/Views/errors/404.php500.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ào storage/logs/.

Để lại bình luận

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