Tema
Patrón MVC desde cero
Cuando un proyecto crece, mezclar consultas SQL, lógica y HTML en el mismo archivo se vuelve un caos. El patrón MVC (Modelo-Vista-Controlador) separa esas responsabilidades. Es la arquitectura de Laravel, Symfony, CodeIgniter y casi cualquier framework. Construirlo tú mismo desde cero es la mejor forma de entender cómo funcionan por dentro.
Concepto
Piensa en un restaurante:
- Modelo (la cocina): sabe dónde están los ingredientes (la base de datos) y cómo prepararlos. No le importa quién pidió el plato.
- Vista (la presentación del plato): cómo se ve lo que llega a la mesa. Sólo HTML, sin consultas.
- Controlador (el mesero): recibe el pedido (la petición HTTP), le pide a la cocina lo necesario y entrega el plato presentado.
Y además:
- Front controller (
public/index.php): la puerta única de entrada. Todas las peticiones pasan por ahí. - Router: decide qué controlador atiende cada URL.
Flujo de una petición a /productos/5:
txt
Navegador ──> public/index.php ──> Router ──> ProductoController::ver(5)
│
Producto::buscar(5) (Modelo, consulta la BD)
│
vista 'productos/ver' (HTML con los datos)
│
Navegador <────────────────── respuesta HTML ───────┘Estructura del proyecto
txt
mini-mvc/
├── composer.json
├── .env
├── public/ ← ÚNICA carpeta accesible desde el navegador
│ ├── index.php
│ ├── .htaccess
│ └── css/app.css
├── app/
│ ├── Core/ ← el "framework"
│ │ ├── Router.php
│ │ ├── Controller.php
│ │ ├── Model.php
│ │ ├── Database.php
│ │ └── View.php
│ ├── Controllers/
│ │ ├── HomeController.php
│ │ └── ProductoController.php
│ └── Models/
│ └── Producto.php
├── views/
│ ├── layouts/main.php
│ ├── home.php
│ ├── errors/404.php
│ └── productos/
│ ├── index.php
│ ├── ver.php
│ └── form.php
└── routes.phpjson
{
"require": {
"php": ">=8.3",
"vlucas/phpdotenv": "^5.6"
},
"autoload": {
"psr-4": { "App\\": "app/" }
}
}Ejecuta composer install. Usaremos la tabla productos de la lección PDO.
1. El front controller
php
<?php
// public/index.php
declare(strict_types=1);
// Ruta raíz del proyecto (una carpeta arriba de public/)
define('BASE_PATH', dirname(__DIR__));
require BASE_PATH . '/vendor/autoload.php';
Dotenv\Dotenv::createImmutable(BASE_PATH)->load();
session_start();
// Errores visibles sólo en desarrollo
ini_set('display_errors', $_ENV['APP_ENV'] === 'local' ? '1' : '0');
$router = new App\Core\Router();
require BASE_PATH . '/routes.php';
// Despachar la petición actual
$router->dispatch($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']);apache
# public/.htaccess
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L]env
# .env
APP_ENV=local
DB_HOST=127.0.0.1
DB_NAME=tienda
DB_USER=root
DB_PASS=2. El router
php
<?php
// app/Core/Router.php
declare(strict_types=1);
namespace App\Core;
final class Router
{
/** @var array<string, array<string, array{0: class-string, 1: string}>> */
private array $rutas = ['GET' => [], 'POST' => []];
public function get(string $patron, array $accion): void
{
$this->rutas['GET'][$patron] = $accion;
}
public function post(string $patron, array $accion): void
{
$this->rutas['POST'][$patron] = $accion;
}
public function dispatch(string $metodo, string $uri): void
{
$ruta = '/' . trim(parse_url($uri, PHP_URL_PATH), '/');
foreach ($this->rutas[$metodo] ?? [] as $patron => [$clase, $metodoControlador]) {
// Convierte '/productos/{id}' en la expresión regular '#^/productos/(\d+)$#'
$regex = '#^' . preg_replace('#\{[a-z]+\}#', '(\d+)', $patron) . '$#';
if (preg_match($regex, $ruta, $coincidencias)) {
array_shift($coincidencias); // quita la coincidencia completa
$parametros = array_map('intval', $coincidencias);
$controlador = new $clase();
$controlador->$metodoControlador(...$parametros); // ... expande el array en argumentos
return;
}
}
http_response_code(404);
View::render('errors/404', ['titulo' => 'Página no encontrada']);
}
}Este router admite parámetros numéricos como {id}. Laravel hace lo mismo con muchas más opciones.
3. Las rutas
php
<?php
// routes.php
use App\Controllers\HomeController;
use App\Controllers\ProductoController;
/** @var App\Core\Router $router */
$router->get('/', [HomeController::class, 'index']);
$router->get('/productos', [ProductoController::class, 'index']);
$router->get('/productos/crear', [ProductoController::class, 'crear']);
$router->post('/productos', [ProductoController::class, 'guardar']);
$router->get('/productos/{id}', [ProductoController::class, 'ver']);
$router->get('/productos/{id}/editar', [ProductoController::class, 'editar']);
$router->post('/productos/{id}', [ProductoController::class, 'actualizar']);
$router->post('/productos/{id}/borrar', [ProductoController::class, 'borrar']);4. Conexión y modelo base
php
<?php
// app/Core/Database.php
declare(strict_types=1);
namespace App\Core;
use PDO;
final class Database
{
private static ?PDO $pdo = null;
public static function pdo(): PDO
{
return self::$pdo ??= new PDO(
"mysql:host={$_ENV['DB_HOST']};dbname={$_ENV['DB_NAME']};charset=utf8mb4",
$_ENV['DB_USER'],
$_ENV['DB_PASS'],
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
}
}php
<?php
// app/Core/Model.php
declare(strict_types=1);
namespace App\Core;
/**
* Modelo base con operaciones CRUD genéricas.
* Las clases hijas sólo definen $tabla y $campos.
*/
abstract class Model
{
protected static string $tabla;
/** Columnas que se pueden asignar masivamente (lista blanca) */
protected static array $campos = [];
public static function todos(string $orden = 'id DESC'): array
{
return Database::pdo()->query('SELECT * FROM ' . static::$tabla . " ORDER BY $orden")->fetchAll();
}
public static function buscar(int $id): ?array
{
$stmt = Database::pdo()->prepare('SELECT * FROM ' . static::$tabla . ' WHERE id = ?');
$stmt->execute([$id]);
return $stmt->fetch() ?: null;
}
public static function crear(array $datos): int
{
$datos = array_intersect_key($datos, array_flip(static::$campos)); // sólo campos permitidos
$columnas = implode(', ', array_keys($datos));
$marcadores = implode(', ', array_map(fn ($c) => ":$c", array_keys($datos)));
$stmt = Database::pdo()->prepare('INSERT INTO ' . static::$tabla . " ($columnas) VALUES ($marcadores)");
$stmt->execute($datos);
return (int) Database::pdo()->lastInsertId();
}
public static function actualizar(int $id, array $datos): void
{
$datos = array_intersect_key($datos, array_flip(static::$campos));
$sets = implode(', ', array_map(fn ($c) => "$c = :$c", array_keys($datos)));
$stmt = Database::pdo()->prepare('UPDATE ' . static::$tabla . " SET $sets WHERE id = :id");
$stmt->execute($datos + ['id' => $id]);
}
public static function borrar(int $id): void
{
Database::pdo()->prepare('DELETE FROM ' . static::$tabla . ' WHERE id = ?')->execute([$id]);
}
}static::$tabla (y no self::$tabla) usa el valor de la clase hija que hace la llamada. Se llama late static binding.
php
<?php
// app/Models/Producto.php
declare(strict_types=1);
namespace App\Models;
use App\Core\Model;
final class Producto extends Model
{
protected static string $tabla = 'productos';
protected static array $campos = ['nombre', 'descripcion', 'precio', 'stock'];
// Reglas de negocio propias del modelo
public static function validar(array $datos): array
{
$errores = [];
if (mb_strlen(trim($datos['nombre'] ?? '')) < 3) {
$errores['nombre'] = 'Mínimo 3 caracteres.';
}
if (!is_numeric($datos['precio'] ?? null) || $datos['precio'] <= 0) {
$errores['precio'] = 'El precio debe ser mayor que 0.';
}
if (filter_var($datos['stock'] ?? '', FILTER_VALIDATE_INT, ['options' => ['min_range' => 0]]) === false) {
$errores['stock'] = 'El stock debe ser un entero positivo.';
}
return $errores;
}
public static function bajoStock(int $minimo = 5): array
{
$stmt = \App\Core\Database::pdo()->prepare('SELECT * FROM productos WHERE stock < ? ORDER BY stock');
$stmt->execute([$minimo]);
return $stmt->fetchAll();
}
}5. Vistas y layout
php
<?php
// app/Core/View.php
declare(strict_types=1);
namespace App\Core;
final class View
{
/**
* Renderiza una vista dentro de un layout.
* La vista se captura en $contenido y luego se inserta en el layout.
*/
public static function render(string $vista, array $datos = [], string $layout = 'main'): void
{
extract($datos, EXTR_SKIP);
ob_start();
require BASE_PATH . "/views/$vista.php";
$contenido = ob_get_clean();
require BASE_PATH . "/views/layouts/$layout.php";
}
}Las vistas necesitan funciones de ayuda globales (escapar HTML, token CSRF, valores anteriores del formulario). Las ponemos en un archivo de helpers que Composer carga siempre. Agrega a composer.json:
json
"autoload": {
"psr-4": { "App\\": "app/" },
"files": ["app/helpers.php"]
}php
<?php
// app/helpers.php — funciones globales
declare(strict_types=1);
function e(mixed $valor): string
{
return htmlspecialchars((string) $valor, ENT_QUOTES, 'UTF-8');
}
function csrf_token(): string
{
return $_SESSION['csrf'] ??= bin2hex(random_bytes(32));
}
function csrf_field(): string
{
return '<input type="hidden" name="_csrf" value="' . csrf_token() . '">';
}
function old(string $campo, mixed $porDefecto = ''): string
{
return e($_SESSION['_old'][$campo] ?? $porDefecto);
}Ejecuta composer dump-autoload para que Composer registre el archivo.
php
<?php // views/layouts/main.php ?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= e($titulo ?? 'Mini MVC') ?></title>
<link rel="stylesheet" href="/css/app.css">
</head>
<body>
<nav>
<a href="/">Inicio</a>
<a href="/productos">Productos</a>
</nav>
<?php if (!empty($_SESSION['flash'])): ?>
<div class="flash"><?= e($_SESSION['flash']) ?></div>
<?php unset($_SESSION['flash']); ?>
<?php endif; ?>
<main>
<?= $contenido /* ya viene escapado desde la vista */ ?>
</main>
</body>
</html>php
<?php // views/productos/index.php ?>
<h1>Productos</h1>
<a href="/productos/crear">+ Nuevo producto</a>
<table>
<tr><th>Nombre</th><th>Precio</th><th>Stock</th><th></th></tr>
<?php foreach ($productos as $p): ?>
<tr>
<td><a href="/productos/<?= (int) $p['id'] ?>"><?= e($p['nombre']) ?></a></td>
<td>$<?= number_format((float) $p['precio'], 2) ?></td>
<td><?= (int) $p['stock'] ?></td>
<td>
<a href="/productos/<?= (int) $p['id'] ?>/editar">Editar</a>
<form method="post" action="/productos/<?= (int) $p['id'] ?>/borrar" style="display:inline">
<?= csrf_field() ?>
<button onclick="return confirm('¿Borrar?')">Borrar</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</table>php
<?php // views/productos/form.php — sirve para crear y editar ?>
<h1><?= $producto ? 'Editar producto' : 'Nuevo producto' ?></h1>
<form method="post" action="<?= $producto ? '/productos/' . (int) $producto['id'] : '/productos' ?>">
<?= csrf_field() ?>
<label>Nombre
<input name="nombre" value="<?= old('nombre', $producto['nombre'] ?? '') ?>">
</label>
<small><?= e($errores['nombre'] ?? '') ?></small>
<label>Descripción
<textarea name="descripcion"><?= old('descripcion', $producto['descripcion'] ?? '') ?></textarea>
</label>
<label>Precio
<input name="precio" type="number" step="0.01" value="<?= old('precio', $producto['precio'] ?? '') ?>">
</label>
<small><?= e($errores['precio'] ?? '') ?></small>
<label>Stock
<input name="stock" type="number" value="<?= old('stock', $producto['stock'] ?? 0) ?>">
</label>
<small><?= e($errores['stock'] ?? '') ?></small>
<button>Guardar</button>
</form>php
<?php // views/productos/ver.php ?>
<h1><?= e($producto['nombre']) ?></h1>
<p><?= nl2br(e($producto['descripcion'])) ?></p>
<p><strong>$<?= number_format((float) $producto['precio'], 2) ?></strong> · <?= (int) $producto['stock'] ?> en stock</p>
<a href="/productos">Volver</a>php
<?php // views/errors/404.php ?>
<h1>404</h1>
<p>La página que buscas no existe. <a href="/">Volver al inicio</a></p>6. Controladores
php
<?php
// app/Core/Controller.php
declare(strict_types=1);
namespace App\Core;
abstract class Controller
{
protected function view(string $vista, array $datos = []): void
{
View::render($vista, $datos);
}
protected function redirect(string $url, ?string $flash = null): never
{
if ($flash) {
$_SESSION['flash'] = $flash;
}
header("Location: $url");
exit;
}
protected function verificarCsrf(): void
{
if (!hash_equals($_SESSION['csrf'] ?? '', $_POST['_csrf'] ?? '')) {
http_response_code(419);
exit('Token CSRF inválido.');
}
}
protected function notFound(): never
{
http_response_code(404);
View::render('errors/404', ['titulo' => 'No encontrado']);
exit;
}
}php
<?php
// app/Controllers/HomeController.php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Controller;
use App\Models\Producto;
final class HomeController extends Controller
{
public function index(): void
{
$this->view('home', [
'titulo' => 'Inicio',
'bajoStock' => Producto::bajoStock(),
]);
}
}php
<?php // views/home.php ?>
<h1>Panel</h1>
<h2>Productos con poco stock</h2>
<ul>
<?php foreach ($bajoStock as $p): ?>
<li><?= e($p['nombre']) ?>: <?= (int) $p['stock'] ?></li>
<?php endforeach; ?>
</ul>php
<?php
// app/Controllers/ProductoController.php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Controller;
use App\Models\Producto;
final class ProductoController extends Controller
{
public function index(): void
{
$this->view('productos/index', [
'titulo' => 'Productos',
'productos' => Producto::todos('nombre ASC'),
]);
}
public function ver(int $id): void
{
$producto = Producto::buscar($id) ?? $this->notFound();
$this->view('productos/ver', ['titulo' => $producto['nombre'], 'producto' => $producto]);
}
public function crear(): void
{
$this->formulario(null);
}
public function guardar(): void
{
$this->verificarCsrf();
$errores = Producto::validar($_POST);
if ($errores) {
$this->formulario(null, $errores);
return;
}
$id = Producto::crear($_POST);
$this->redirect("/productos/$id", 'Producto creado.');
}
public function editar(int $id): void
{
$producto = Producto::buscar($id) ?? $this->notFound();
$this->formulario($producto);
}
public function actualizar(int $id): void
{
$this->verificarCsrf();
$producto = Producto::buscar($id) ?? $this->notFound();
$errores = Producto::validar($_POST);
if ($errores) {
$this->formulario($producto, $errores);
return;
}
Producto::actualizar($id, $_POST);
$this->redirect("/productos/$id", 'Producto actualizado.');
}
public function borrar(int $id): void
{
$this->verificarCsrf();
Producto::borrar($id);
$this->redirect('/productos', 'Producto eliminado.');
}
// Muestra el formulario repoblando lo que el usuario envió si hubo errores
private function formulario(?array $producto, array $errores = []): void
{
$_SESSION['_old'] = $errores ? $_POST : [];
$this->view('productos/form', [
'titulo' => $producto ? 'Editar producto' : 'Nuevo producto',
'producto' => $producto,
'errores' => $errores,
]);
unset($_SESSION['_old']);
}
}Paso a paso: ejecutarlo
bash
composer install
php -S localhost:8000 -t publicAbre http://localhost:8000/productos. Con XAMPP/Laragon, apunta el dominio virtual a la carpeta public/ (Laragon lo hace solo si nombras la carpeta y usas mini-mvc.test).
Buena práctica
Que el servidor web sirva sólo la carpeta public/. Así nadie puede descargar tu .env, tus controladores o la carpeta vendor/. Laravel funciona exactamente igual.
Error común
Poner consultas SQL en las vistas o HTML en los modelos. Si una vista necesita datos, el controlador se los pasa. Si notas que un controlador tiene mucha lógica de negocio, muévela al modelo o a una clase de servicio ("controladores delgados, modelos gordos").
Lo que acabas de construir vs. Laravel
| Tu mini MVC | Laravel |
|---|---|
public/index.php | public/index.php |
routes.php con $router->get() | routes/web.php con Route::get() |
App\Core\Model | Eloquent (Illuminate\Database\Eloquent\Model) |
View::render() con PHP | Blade (view('productos.index')) |
$campos (lista blanca) | $fillable |
csrf_field() | @csrf |
old() | old() |
Cuando llegues al curso de Laravel, todo te resultará familiar.
Ejercicios
- Agrega soporte para rutas con nombre de texto (por ejemplo
/categorias/{slug}) en el router: los parámetros{slug}deben aceptar letras, números y guiones. - Crea un modelo
Categoriay unCategoriaControllercon listado y alta. - Añade un método
where(string $columna, mixed $valor): arrayal modelo base, protegiendo el nombre de columna con la lista$campos.
Ver solución
php
<?php
// 1: en Router::dispatch(), sustituye la construcción de $regex y $parametros
$regex = '#^' . preg_replace_callback(
'#\{([a-z]+)\}#',
fn ($m) => $m[1] === 'id' ? '(\d+)' : '([a-z0-9-]+)',
$patron
) . '$#';
if (preg_match($regex, $ruta, $coincidencias)) {
array_shift($coincidencias);
// Convierte a int sólo los valores numéricos
$parametros = array_map(fn ($v) => ctype_digit($v) ? (int) $v : $v, $coincidencias);
(new $clase())->$metodoControlador(...$parametros);
return;
}php
<?php
// 2: app/Models/Categoria.php
namespace App\Models;
use App\Core\Model;
final class Categoria extends Model
{
protected static string $tabla = 'categorias';
protected static array $campos = ['nombre', 'slug'];
}php
<?php
// app/Controllers/CategoriaController.php
namespace App\Controllers;
use App\Core\Controller;
use App\Models\Categoria;
final class CategoriaController extends Controller
{
public function index(): void
{
$this->view('categorias/index', ['titulo' => 'Categorías', 'categorias' => Categoria::todos('nombre')]);
}
public function guardar(): void
{
$this->verificarCsrf();
$nombre = trim($_POST['nombre'] ?? '');
if ($nombre === '') {
$this->redirect('/categorias', 'El nombre es obligatorio.');
}
$slug = trim(preg_replace('/[^a-z0-9]+/', '-', strtolower($nombre)), '-');
Categoria::crear(['nombre' => $nombre, 'slug' => $slug]);
$this->redirect('/categorias', 'Categoría creada.');
}
}
// routes.php:
// $router->get('/categorias', [CategoriaController::class, 'index']);
// $router->post('/categorias', [CategoriaController::class, 'guardar']);php
<?php
// 3: en App\Core\Model
public static function where(string $columna, mixed $valor): array
{
if (!in_array($columna, [...static::$campos, 'id'], true)) {
throw new \InvalidArgumentException("Columna no permitida: $columna");
}
$stmt = Database::pdo()->prepare('SELECT * FROM ' . static::$tabla . " WHERE $columna = ?");
$stmt->execute([$valor]);
return $stmt->fetchAll();
}Resumen
- MVC separa datos (Modelo), presentación (Vista) y coordinación (Controlador).
- Un front controller en
public/index.phprecibe todas las peticiones y el router las reparte. - El modelo base con
static::y listas blancas de campos evita repetir SQL. - Las vistas sólo muestran datos (escapados); los controladores no generan HTML directamente.
- Sólo
public/debe ser accesible desde el navegador. - Es exactamente la arquitectura de Laravel, a pequeña escala.