Глава 5.1: Паттерн MVC — Model, View, Controller — теория и практика без фреймворков
🎯 Что ты узнаешь
- Почему спагетти-код — это плохо и как MVC решает эту проблему
- Что такое Model, View, Controller и за что отвечает каждый слой
- Как построить MVC-приложение с нуля без фреймворков
- Практические примеры разделения логики
- Распространённые ошибки при реализации MVC
🍝 Проблема: спагетти-код
Представь типичный PHP-файл начинающего разработчика:
<?php
// products.php - ПЛОХОЙ ПРИМЕР
// Подключение к БД
$pdo = new PDO('mysql:host=localhost;dbname=shop', 'root', '');
// Получение параметров
$category = $_GET['category'] ?? 'all';
// HTML-шапка
echo '<html><head><title>Товары</title></head><body>';
echo '<h1>Наши товары</h1>';
// Запрос к БД
if ($category === 'all') {
$stmt = $pdo->query('SELECT * FROM products');
} else {
$stmt = $pdo->prepare('SELECT * FROM products WHERE category = ?');
$stmt->execute([$category]);
}
// Вывод данных
echo '<div class="products">';
while ($product = $stmt->fetch()) {
echo '<div class="product">';
echo '<h2>' . htmlspecialchars($product['name']) . '</h2>';
echo '<p>Цена: ' . $product['price'] . ' руб.</p>';
// Вычисление скидки
if ($product['discount'] > 0) {
$newPrice = $product['price'] - ($product['price'] * $product['discount'] / 100);
echo '<p class="discount">Со скидкой: ' . $newPrice . ' руб.</p>';
}
echo '</div>';
}
echo '</div>';
// Футер
echo '</body></html>';
?>Что здесь не так?
- Невозможно переиспользовать логику — расчёт скидки "зашит" в HTML
- Нельзя протестировать — код выводит данные сразу
- Сложно изменить дизайн — HTML смешан с PHP
- Дублирование кода — подключение к БД в каждом файле
- Сложно работать в команде — дизайнер не может менять вёрстку без PHP
🏗️ Решение: паттерн MVC
MVC (Model-View-Controller) — это архитектурный паттерн, который разделяет приложение на три слоя:
┌─────────────┐
│ Browser │
└──────┬──────┘
│ HTTP Request
↓
┌─────────────────────────────────────┐
│ CONTROLLER │
│ • Принимает запросы │
│ • Вызывает Model для данных │
│ • Передаёт данные во View │
└──────┬──────────────────────────────┘
│
├──→ ┌─────────────────────────┐
│ │ MODEL │
│ │ • Работа с БД │
│ │ • Бизнес-логика │
│ │ • Валидация данных │
│ └─────────────────────────┘
│
└──→ ┌─────────────────────────┐
│ VIEW │
│ • HTML-шаблоны │
│ • Отображение данных │
│ • Никакой логики │
└─────────────────────────┘Принцип разделения ответственности
| Слой | За что отвечает | Чего НЕ делает |
|---|---|---|
| Model | Данные и бизнес-логика | Не знает про HTML и HTTP |
| View | Визуальное представление | Не обращается к БД |
| Controller | Координация между M и V | Минимум логики |
📦 Model — слой данных
Model отвечает за:
- Работу с базой данных
- Бизнес-логику (расчёты, правила)
- Валидацию данных
Пример базовой модели
<?php
// models/Product.php
class Product
{
private PDO $pdo;
public function __construct(PDO $pdo)
{
$this->pdo = $pdo;
}
/**
* Получить все товары или по категории
*/
public function getAll(?string $category = null): array
{
if ($category === null) {
$stmt = $this->pdo->query('SELECT * FROM products ORDER BY name');
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
$stmt = $this->pdo->prepare('SELECT * FROM products WHERE category = ? ORDER BY name');
$stmt->execute([$category]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Получить товар по ID
*/
public function findById(int $id): ?array
{
$stmt = $this->pdo->prepare('SELECT * FROM products WHERE id = ?');
$stmt->execute([$id]);
$product = $stmt->fetch(PDO::FETCH_ASSOC);
return $product ?: null;
}
/**
* Создать новый товар
*/
public function create(array $data): int
{
// Валидация
if (empty($data['name']) || empty($data['price'])) {
throw new InvalidArgumentException('Название и цена обязательны');
}
if ($data['price'] < 0) {
throw new InvalidArgumentException('Цена не может быть отрицательной');
}
$stmt = $this->pdo->prepare('
INSERT INTO products (name, price, category, discount, description)
VALUES (:name, :price, :category, :discount, :description)
');
$stmt->execute([
'name' => $data['name'],
'price' => $data['price'],
'category' => $data['category'] ?? 'general',
'discount' => $data['discount'] ?? 0,
'description' => $data['description'] ?? ''
]);
return (int) $this->pdo->lastInsertId();
}
/**
* Обновить товар
*/
public function update(int $id, array $data): bool
{
$stmt = $this->pdo->prepare('
UPDATE products
SET name = :name, price = :price, category = :category,
discount = :discount, description = :description
WHERE id = :id
');
return $stmt->execute([
'id' => $id,
'name' => $data['name'],
'price' => $data['price'],
'category' => $data['category'],
'discount' => $data['discount'] ?? 0,
'description' => $data['description'] ?? ''
]);
}
/**
* Удалить товар
*/
public function delete(int $id): bool
{
$stmt = $this->pdo->prepare('DELETE FROM products WHERE id = ?');
return $stmt->execute([$id]);
}
/**
* Бизнес-логика: рассчитать цену со скидкой
*/
public function calculateDiscountedPrice(array $product): float
{
if ($product['discount'] <= 0) {
return (float) $product['price'];
}
$discount = $product['price'] * ($product['discount'] / 100);
return round($product['price'] - $discount, 2);
}
}Ключевые моменты:
- Model не содержит HTML
- Model не знает, откуда пришли данные (из формы, API, консоли)
- Вся бизнес-логика инкапсулирована в методах
- Model можно легко протестировать
🖼️ View — слой представления
View отвечает только за отображение данных. Никакой логики!
Простой шаблон
<?php
// views/products/list.php
// Файл получает переменную $products
?>
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Список товаров</title>
<style>
.product { border: 1px solid #ddd; padding: 15px; margin: 10px 0; }
.discount { color: red; font-weight: bold; }
</style>
</head>
<body>
<h1>Наши товары</h1>
<div class="products">
<?php if (empty($products)): ?>
<p>Товары не найдены</p>
<?php else: ?>
<?php foreach ($products as $product): ?>
<div class="product">
<h2><?= htmlspecialchars($product['name']) ?></h2>
<p>Категория: <?= htmlspecialchars($product['category']) ?></p>
<p>Цена: <?= number_format($product['price'], 2) ?> руб.</p>
<?php if ($product['discounted_price'] < $product['price']): ?>
<p class="discount">
Со скидкой: <?= number_format($product['discounted_price'], 2) ?> руб.
</p>
<?php endif; ?>
<a href="/products/show?id=<?= $product['id'] ?>">Подробнее</a>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</body>
</html>Шаблон для отдельного товара
<?php
// views/products/show.php
// Получает переменную $product
?>
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title><?= htmlspecialchars($product['name']) ?></title>
</head>
<body>
<h1><?= htmlspecialchars($product['name']) ?></h1>
<div class="product-details">
<p><strong>Категория:</strong> <?= htmlspecialchars($product['category']) ?></p>
<p><strong>Цена:</strong> <?= number_format($product['price'], 2) ?> руб.</p>
<?php if ($product['discount'] > 0): ?>
<p><strong>Скидка:</strong> <?= $product['discount'] ?>%</p>
<p class="discount">
<strong>Цена со скидкой:</strong>
<?= number_format($product['discounted_price'], 2) ?> руб.
</p>
<?php endif; ?>
<?php if (!empty($product['description'])): ?>
<div class="description">
<h2>Описание</h2>
<p><?= nl2br(htmlspecialchars($product['description'])) ?></p>
</div>
<?php endif; ?>
</div>
<a href="/products">← Вернуться к списку</a>
</body>
</html>Что можно во View:
✅ Условия для отображения (if, тернарный оператор)
✅ Циклы для перебора данных (foreach)
✅ Экранирование (htmlspecialchars)
✅ Форматирование (number_format, date)
Что НЕЛЬЗЯ во View:
❌ Запросы к базе данных
❌ Обработка форм
❌ Бизнес-логику (расчёты, валидацию)
❌ Работу с файлами, сессиями
🎮 Controller — слой управления
Controller связывает Model и View:
- Принимает HTTP-запрос
- Вызывает нужные методы Model
- Подготавливает данные для View
- Отдаёт View браузеру
Базовый контроллер
<?php
// controllers/ProductController.php
class ProductController
{
private Product $productModel;
public function __construct(Product $productModel)
{
$this->productModel = $productModel;
}
/**
* Показать список товаров
* URL: /products или /products?category=electronics
*/
public function index(): void
{
$category = $_GET['category'] ?? null;
// Получаем данные из модели
$products = $this->productModel->getAll($category);
// Добавляем вычисляемые поля
foreach ($products as &$product) {
$product['discounted_price'] = $this->productModel->calculateDiscountedPrice($product);
}
// Отображаем view
$this->render('products/list', [
'products' => $products
]);
}
/**
* Показать один товар
* URL: /products/show?id=5
*/
public function show(): void
{
$id = (int) ($_GET['id'] ?? 0);
if ($id <= 0) {
$this->redirect('/products');
return;
}
$product = $this->productModel->findById($id);
if ($product === null) {
$this->render('errors/404');
return;
}
// Добавляем цену со скидкой
$product['discounted_price'] = $this->productModel->calculateDiscountedPrice($product);
$this->render('products/show', [
'product' => $product
]);
}
/**
* Форма создания товара
* URL: /products/create
*/
public function create(): void
{
$this->render('products/create');
}
/**
* Сохранить новый товар
* URL: /products/store (POST)
*/
public function store(): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$this->redirect('/products/create');
return;
}
try {
$data = [
'name' => $_POST['name'] ?? '',
'price' => (float) ($_POST['price'] ?? 0),
'category' => $_POST['category'] ?? 'general',
'discount' => (int) ($_POST['discount'] ?? 0),
'description' => $_POST['description'] ?? ''
];
$id = $this->productModel->create($data);
// Редирект на созданный товар
$this->redirect("/products/show?id={$id}");
} catch (InvalidArgumentException $e) {
// Показываем форму с ошибкой
$this->render('products/create', [
'error' => $e->getMessage(),
'old_data' => $_POST
]);
}
}
/**
* Удалить товар
* URL: /products/delete?id=5 (POST)
*/
public function delete(): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$this->redirect('/products');
return;
}
$id = (int) ($_POST['id'] ?? 0);
if ($id > 0) {
$this->productModel->delete($id);
}
$this->redirect('/products');
}
/**
* Вспомогательный метод: рендер view
*/
private function render(string $view, array $data = []): void
{
// Извлекаем переменные из массива
extract($data);
// Подключаем файл шаблона
require __DIR__ . "/../views/{$view}.php";
}
/**
* Вспомогательный метод: редирект
*/
private function redirect(string $url): void
{
header("Location: {$url}");
exit;
}
}Что делает Controller:
- Обрабатывает параметры запроса (
$_GET,$_POST) - Вызывает методы модели
- Подготавливает данные для view
- Решает, какой view показать
- Делает редиректы
Что НЕ делает Controller:
- Не содержит SQL-запросов
- Не содержит HTML
- Не содержит сложной бизнес-логики
🔗 Всё вместе: Front Controller
Теперь нужна единая точка входа, которая будет направлять запросы к нужным контроллерам.
<?php
// public/index.php — единственный PHP-файл, доступный извне
require_once __DIR__ . '/../config/database.php';
require_once __DIR__ . '/../models/Product.php';
require_once __DIR__ . '/../controllers/ProductController.php';
// Подключение к БД
$pdo = getConnection(); // функция из database.php
// Создаём модель и контроллер
$productModel = new Product($pdo);
$productController = new ProductController($productModel);
// Простой роутинг
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
switch ($uri) {
case '/':
case '/products':
$productController->index();
break;
case '/products/show':
$productController->show();
break;
case '/products/create':
$productController->create();
break;
case '/products/store':
$productController->store();
break;
case '/products/delete':
$productController->delete();
break;
default:
http_response_code(404);
require __DIR__ . '/../views/errors/404.php';
break;
}Настройка .htaccess
Чтобы все запросы шли через index.php:
# public/.htaccess
RewriteEngine On
# Если файл или папка существуют — отдать их
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
# Иначе — отправить на index.php
RewriteRule ^(.*)$ index.php [QSA,L]📁 Структура проекта
my-mvc-app/
├── public/ # Доступно извне (document root)
│ ├── index.php # Front Controller
│ ├── .htaccess
│ └── css/
│ └── style.css
├── config/
│ └── database.php # Подключение к БД
├── models/
│ ├── Product.php
│ └── User.php
├── views/
│ ├── products/
│ │ ├── list.php
│ │ ├── show.php
│ │ └── create.php
│ └── errors/
│ └── 404.php
└── controllers/
└── ProductController.phpФайл database.php
<?php
// config/database.php
function getConnection(): PDO
{
static $pdo = null;
if ($pdo === null) {
$dsn = 'mysql:host=localhost;dbname=shop;charset=utf8mb4';
$username = 'root';
$password = '';
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false
];
$pdo = new PDO($dsn, $username, $password, $options);
}
return $pdo;
}🎨 Улучшаем View: Layouts
Чтобы не дублировать шапку и подвал, создадим layout:
<?php
// views/layouts/main.php
?>
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title><?= $title ?? 'Мой магазин' ?></title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<header>
<nav>
<a href="/products">Товары</a>
<a href="/cart">Корзина</a>
<a href="/about">О нас</a>
</nav>
</header>
<main>
<?= $content ?>
</main>
<footer>
<p>© 2025 Мой магазин</p>
</footer>
</body>
</html>Теперь обновим метод render() в контроллере:
private function render(string $view, array $data = []): void
{
extract($data);
// Захватываем содержимое view в переменную
ob_start();
require __DIR__ . "/../views/{$view}.php";
$content = ob_get_clean();
// Оборачиваем в layout
require __DIR__ . '/../views/layouts/main.php';
}Теперь шаблон товара упрощается:
<?php
// views/products/show.php
?>
<h1><?= htmlspecialchars($product['name']) ?></h1>
<div class="product-details">
<p><strong>Цена:</strong> <?= number_format($product['price'], 2) ?> руб.</p>
<!-- остальное содержимое -->
</div>
<a href="/products">← Назад</a>⚠️ Распространённые ошибки
❌ Ошибка 1: Толстый контроллер
// ПЛОХО
public function index(): void
{
$products = $this->productModel->getAll();
// Контроллер НЕ должен содержать бизнес-логику!
foreach ($products as &$product) {
if ($product['discount'] > 0) {
$newPrice = $product['price'] - ($product['price'] * $product['discount'] / 100);
$product['discounted_price'] = round($newPrice, 2);
} else {
$product['discounted_price'] = $product['price'];
}
// Ещё 50 строк вычислений...
}
$this->render('products/list', ['products' => $products]);
}Решение: Перенести логику в модель
// ХОРОШО
public function index(): void
{
$products = $this->productModel->getAll();
foreach ($products as &$product) {
$product['discounted_price'] = $this->productModel->calculateDiscountedPrice($product);
}
$this->render('products/list', ['products' => $products]);
}❌ Ошибка 2: Логика во View
<!-- ПЛОХО -->
<?php
// Запрос к БД прямо во view!
$categories = $pdo->query('SELECT * FROM categories')->fetchAll();
?>
<select name="category">
<?php foreach ($categories as $cat): ?>
<option><?= $cat['name'] ?></option>
<?php endforeach; ?>
</select>Решение: Передать данные из контроллера
// Контроллер
$categories = $this->categoryModel->getAll();
$this->render('products/create', ['categories' => $categories]);❌ Ошибка 3: Модель знает про HTTP
// ПЛОХО
class Product
{
public function create(): int
{
// Модель НЕ должна обращаться к $_POST!
$name = $_POST['name'];
$price = $_POST['price'];
// ...
}
}Решение: Передавать данные в метод
// ХОРОШО
class Product
{
public function create(array $data): int
{
// Модель получает чистые данные
$name = $data['name'];
$price = $data['price'];
// ...
}
}🏋️ Практические задания
Задание 1: Базовый блог
Создай MVC-приложение для блога:
Модель
Postс методами:getAll()— все постыfindById($id)— пост по IDcreate($data)— создать пост
Контроллер
PostControllerс действиями:index()— список постовshow()— один постcreate()— формаstore()— сохранение
Views:
posts/list.phpposts/show.phpposts/create.php
Структура БД:
CREATE TABLE posts (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
content TEXT,
author VARCHAR(100),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);Задание 2: Категории товаров
Добавь к существующему приложению:
- Модель
Categoryс методами получения всех категорий - Фильтрацию товаров по категории
- View для списка категорий
- Связь между товарами и категориями
Задание 3: Валидация в модели
Расширь модель Product:
public function validate(array $data): array
{
$errors = [];
if (empty($data['name'])) {
$errors['name'] = 'Название обязательно';
}
if ($data['price'] <= 0) {
$errors['price'] = 'Цена должна быть положительной';
}
// Добавь ещё проверки
return $errors;
}Используй в контроллере перед сохранением.
Задание 4*: Пагинация
Добавь постраничную навигацию:
- Модель получает параметры
limitиoffset - Контроллер вычисляет номер страницы
- View показывает ссылки "Предыдущая / Следующая"
🧪 Самопроверка
Какой слой MVC отвечает за работу с БД?
Ответ
Model. Только модель делает SQL-запросы.Можно ли во View делать
$pdo->query()?Ответ
Нет! View только отображает данные, переданные из контроллера.Что делает Controller?
Ответ
Принимает запрос, вызывает модель, передаёт данные во view, отдаёт ответ.Где должна быть логика расчёта скидки?
Ответ
В Model — это бизнес-логика.Можно ли в Model обращаться к
$_POST?Ответ
Нет! Model не знает про HTTP. Данные передаются в методы как параметры.
📚 Что дальше?
Ты построил MVC-приложение с нуля! Теперь понимаешь:
- Зачем нужно разделение на слои
- Как работает каждый компонент MVC
- Почему важно не смешивать логику и представление
В следующей главе:
- Front Controller и продвинутый роутинг
- Регулярные выражения в маршрутах
- Динамические параметры (
/products/{id}) - Именованные маршруты
🎯 Ключевые выводы
✅ MVC разделяет ответственность — каждый слой делает одно дело
✅ Model — данные и бизнес-логика, не знает про HTTP
✅ View — только отображение, никакой логики
✅ Controller — связывает M и V, минимум логики
✅ Front Controller — единая точка входа для всех запросов
✅ Layouts — переиспользуемые шаблоны для общих элементов
Теперь ты готов к изучению продвинутого роутинга! 🚀