Skip to content

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.php
json
{
    "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 public

Abre 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 MVCLaravel
public/index.phppublic/index.php
routes.php con $router->get()routes/web.php con Route::get()
App\Core\ModelEloquent (Illuminate\Database\Eloquent\Model)
View::render() con PHPBlade (view('productos.index'))
$campos (lista blanca)$fillable
csrf_field()@csrf
old()old()

Cuando llegues al curso de Laravel, todo te resultará familiar.

Ejercicios ​

  1. 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.
  2. Crea un modelo Categoria y un CategoriaController con listado y alta.
  3. Añade un método where(string $columna, mixed $valor): array al 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.php recibe 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.

Academia Web — aprende, copia y construye.