Arquitectura MVC
MVC (Modelo–Vista–Controlador) separa una aplicación en tres responsabilidades claras. Es el patrón que usan casi todos los frameworks modernos (Laravel, Symfony, CodeIgniter), y entenderlo a mano te da una base sólida para cualquiera de ellos.
1.1 · Estructura de carpetas típica
mi-app/
├── public/ # única carpeta visible al navegador
│ └── index.php # front controller (punto de entrada)
├── app/
│ ├── Controllers/ # controladores: ProductoController.php
│ ├── Models/ # modelos: Producto.php
│ ├── Views/ # vistas/plantillas: producto/lista.php
│ └── Core/ # router, conexión BD, etc.
├── config/
│ └── config.php # credenciales y ajustes
└── public/assets/ # css, js, imágenes
public/ es la única que debe ser accesible por el navegador.
Así el código del servidor (modelos, config) queda protegido. El servidor se configura para que
DocumentRoot apunte a public/.1.2 · Responsabilidad única
Cada clase hace una sola cosa. Un controlador no debería escribir SQL; un modelo no debería pintar HTML. Si un archivo crece demasiado, se extrae una clase nueva. Esto se llama el principio de responsabilidad única.
1.3 · El front controller
Todas las peticiones entran por public/index.php. Ese archivo único decide qué controlador y
qué método ejecutar según la URL. A esto se le llama front controller, y es la pieza que
veremos en la siguiente lección con el router.
Enrutamiento y front controller
El router interpreta la URL (/productos/5) y la traduce en
«controlador + acción + parámetros». Es lo que permite URLs limpias y amigables en lugar de archivos sueltos
como ver_producto.php?id=5.
2.1 · Obtener la ruta
$ruta = parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH);
REQUEST_URI contiene la URL pedida (p. ej. /productos/5). Con
parse_url nos quedamos solo con el camino, descartando la query
(?filtro=cara).
2.2 · Un router básico
$ruta = parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH);
$segmentos = explode("/", trim($ruta, "/"));
$controlador = $segmentos[0] ?? "inicio";
$accion = $segmentos[1] ?? "index";
$param = $segmentos[2] ?? null;
require "Controllers/{$controlador}Controller.php";
$clase = ucfirst($controlador) . "Controller";
$app = new $clase;
$app->$accion($param);
require
directo de la URL permite path traversal (por ejemplo ../config/db). La estructura
anterior debe validar/sanear el nombre del controlador contra una lista blanca antes de
incluirlo, o usar una tabla de rutas fijas.2.3 · Tabla de rutas (más seguro)
$rutas = [
"/" => ["Inicio", "index"],
"/productos" => ["Producto", "index"],
"/productos/crear" => ["Producto", "crear"],
];
$ruta = parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH);
if (isset($rutas[$ruta])) {
[$controlador, $accion] = $rutas[$ruta];
} else {
http_response_code(404);
echo "404 Not Found";
exit;
}
require automático de la
sección anterior.APIs REST: fundamentos
Una API REST expone datos de tu aplicación para que otros programas (frontends, apps móviles, otros servidores) los lean o modifiquen, usando HTTP tal cual. En lugar de devolver HTML, devuelve JSON.
3.1 · Métodos HTTP y su significado
// GET /productos -> listar todos
// GET /productos/5 -> ver el 5
// POST /productos -> crear (envía datos en el body)
// PUT /productos/5 -> actualizar completamente
// PATCH /productos/5 -> actualizar en parte
// DELETE /productos/5 -> borrar
3.2 · Leer el método y el body JSON
$metodo = $_SERVER["REQUEST_METHOD"]; // "GET", "POST", ...
$datos = json_decode(file_get_contents("php://input"), true);
if (!$datos) {
http_response_code(400);
echo json_encode(["error" => "JSON no válido"]);
exit;
}
3.3 · Devolver JSON con cabecera correcta
header("Content-Type: application/json; charset=utf-8");
echo json_encode([
"ok" => true,
"datos" => $productos,
]);
Content-Type: application/json le dice al cliente que el cuerpo
es JSON. No usar json_encode y printar un array directamente daría un error fatal: los arrays se
convierten a JSON solo mediante json_encode.3.4 · Códigos de estado
En PHP se fijan con http_response_code($codigo).
APIs REST: autenticación con JWT
Muchas APIs son públicas, pero otras requieren saber quién hace la petición. La forma más habitual hoy es JWT (JSON Web Token): un token firmado que el cliente envía en cada petición.
4.1 · ¿Qué es un JWT?
Un JWT tiene tres partes separadas por puntos (cabecera.payload.firma), codificadas en
base64url:
// cabecera payload firma (HMAC con clave secreta)
eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjo1fQ.s3cr3t0_FirmaFirmada
El payload contiene «claims» (p. ej. user_id, exp). La
firma garantiza que nadie modificó el contenido sin conocer la clave secreta.
firebase/php-jwt (o cuidar el algoritmo
HMAC si lo implementas a mano).4.2 · Leer el token de la cabecera Authorization
$cabeceras = getallheaders();
$auth = $cabeceras["Authorization"] ?? "";
$token = str_starts_with($auth, "Bearer ")
? substr($auth, 7)
: null;
if (!$token) {
http_response_code(401);
echo json_encode(["error" => "Token requerido"]);
exit;
}
4.3 · El flujo completo
// 1) POST /login -> verificas credenciales y devuelves un token
$token = construirJWT(["user_id" => $id, "exp" => time() + 3600]);
echo json_encode(["token" => $token]);
// 2) GET /productos con cabecera: Authorization: Bearer <token>
$payload = verificarJWT($token); // lanza error si la firma no vale
echo json_encode(["usuario" => $payload["user_id"]]);
exp) y el servidor debe
verificar la firma en cada petición protegida. Nunca confíes solo en que «el cliente manda un
id de usuario”: quién es el usuario lo determina el token verificado, no parámetros externos.Testing, logs y despliegue
Un backend profesional no se «prueba a ojo»: se escribe un testing mínimo, se registran logs para diagnosticar, y se despliega siguiendo pasos reproducibles.
5.1 · Testing básico
Empieza por pruebas «unitarias» de la lógica que no toca la BD (por ejemplo validaciones, cálculos) y «funcionales» de los endpoints. Un test sencillo comprueba que una función devuelve lo esperado:
function assertSame($esperado, $real, $etiqueta) {
if ($esperado === $real) {
echo "✅ $etiqueta\n";
} else {
echo "❌ $etiqueta: se esperaba " . var_export($esperado, true) .
" pero hubo " . var_export($real, true) . "\n";
}
}
assertSame(11.5, precioConIva(10), "IVA del 15 %");
assertSame(115, precioConIva(100), "IVA sobre 100");
5.2 · Logs
Registrar lo que ocurre ayuda a diagnosticar fallos que no se ven con var_dump. PHP ofrece
error_log() para escribir en el log del servidor:
$pedido = crearPedido($usuarioId, $productos);
error_log("Pedido creado id=$pedido->id para usuario $usuarioId");
finally o un bloque
catch que registre y responda 500 es una buena práctica.5.3 · Despliegue a producción
vendor ni archivos sensibles)public/display_errors=OffLa diferencia clave entre desarrollo y producción: en producción display_errors está apagado,
los errores van al log, y las credenciales no están en el código.
Proyecto final: integración
En esta lección juntamos TODO lo aprendido en el curso en una aplicación real: una API REST de tienda con arquitectura MVC, base de datos segura y autenticación JWT.
6.1 · El mapa del proyecto
tienda-api/
├── public/
│ └── index.php # front controller + router
├── app/
│ ├── Controllers/ # ProductoController, AuthController
│ ├── Models/ # Producto, Usuario (con PDO preparado)
│ └── Core/
│ ├── Router.php # tabla de rutas
│ ├── Auth.php # generar/verificar JWT
│ └── Database.php # conexión PDO única
├── config/
│ └── config.php # credenciales
└── tests/
└── producto_test.php # pruebas unitarias
6.2 · Orquestar la petición
require __DIR__ . "/../config/config.php";
require __DIR__ . "/../app/Core/Database.php";
require __DIR__ . "/../app/Core/Router.php";
header("Content-Type: application/json; charset=utf-8");
$router = new Router($pdo);
$router->dispatch(parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH));
6.3 · Criterios del proyecto final
public/index.phpEste proyecto es el reto del laboratorio del examen y se valida automáticamente.
✍️ Los 16 ejercicios del bloque
Crea la carpeta php-b4/ dentro de htdocs con la estructura MVC vista en la
lección 1. Para probar las APIs usa tu navegador para los GET y, para los otros métodos, un
cliente como Postman, Insomnia o curl desde la terminal. Los
ejercicios de lógica pura se prueban con php archivo.php.
Sección A · MVC y enrutamiento
1 Estructura del proyecto MVC
Crea la estructura de carpetas MVC de la lección 1 (public/, app/Controllers,
app/Models, app/Core, config/). Dentro de
public/index.php deja una cabecera Content-Type: application/json y un
echo json_encode(["ok" => true, "app" => "tienda-api"]);.
Ver solución
// public/index.php (básico)
header("Content-Type: application/json; charset=utf-8");
echo json_encode([
"ok" => true,
"app" => "tienda-api",
]);
2 Leer la ruta
En index.php obtén la ruta con parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH)
y muéstrala con json_encode. Prueba varias URLs (p. ej. /productos/5?id=1) y
comprueba que la query no aparece en la ruta.
Ver solución
$ruta = parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH);
echo json_encode(["ruta" => $ruta]);
3 Tabla de rutas
Define un array de rutas: "/", "/productos", "/productos/crear"
y "/productos/5". Si la ruta existe, devuelve json_encode(["ruta" => $ruta,
"existe" => true]); si no, responde 404 con un JSON de error.
Ver solución
$rutas = [
"/" => "inicio",
"/productos" => "lista",
"/productos/crear" => "crear",
"/productos/5" => "detalle",
];
$ruta = parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH);
if (isset($rutas[$ruta])) {
echo json_encode(["ruta" => $ruta, "existe" => true]);
} else {
http_response_code(404);
echo json_encode(["error" => "Ruta no encontrada"]);
}
4 Controlador y acción
Crea app/Controllers/ProductoController.php con un método index() que devuelva
un JSON con una lista de productos de ejemplo. En index.php, para la ruta
/productos instancia el controlador y llama a index().
Ver solución
// app/Controllers/ProductoController.php
class ProductoController {
public function index() {
echo json_encode([
$this->datos(),
]);
}
private function datos() {
return ["nombre" => "Ratón", "precio" => 9.99];
}
}
// public/index.php — para la ruta /productos
require __DIR__ . "/../app/Controllers/ProductoController.php";
$app = new ProductoController();
$app->index();
5 Modelo con PDO
Crea app/Models/Producto.php con un método estático todos(PDO $pdo) que
devuelva SELECT * FROM productos usando prepare + execute +
fetchAll. En el controlador usa ese modelo y devuelve el JSON.
Ver solución
// app/Models/Producto.php
class Producto {
public static function todos(PDO $pdo) {
$stmt = $pdo->prepare("SELECT * FROM productos");
$stmt->execute();
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
}
// app/Controllers/ProductoController.php — método index
public function index(PDO $pdo) {
echo json_encode(Producto::todos($pdo));
}
Sección B · APIs REST
6 Leer el método HTTP
En index.php lee $_SERVER["REQUEST_METHOD"] y devuelve un JSON con el método.
Prueba con GET, POST, PUT y DELETE usando Postman o
curl -X.
Ver solución
// curl -X POST http://localhost/php-b4/public/
$metodo = $_SERVER["REQUEST_METHOD"];
echo json_encode(["metodo" => $metodo]);
7 Leer el body JSON
Para una petición POST, lee el cuerpo con
json_decode(file_get_contents("php://input"), true). Si es válido, devuélvelo; si no, devuelve
400 con un JSON de error. Prueba con curl -d '{"nombre":"X"}' -H
"Content-Type: application/json".
Ver solución
$datos = json_decode(file_get_contents("php://input"), true);
if (!$datos) {
http_response_code(400);
echo json_encode(["error" => "JSON no válido"]);
exit;
}
echo json_encode(["recibido" => $datos]);
8 Códigos de estado
Escribe una función responder($data, $codigo = 200) que fije el código con
http_response_code, ponga la cabecera JSON y emita json_encode($data). Úsala para
responder 200, 201, 404 y 422.
Ver solución
function responder($data, $codigo = 200) {
http_response_code($codigo);
header("Content-Type: application/json; charset=utf-8");
echo json_encode($data);
exit;
}
responder(["ok" => true]); // 200
responder(["id" => 5], 201); // 201 Created
responder(["error" => "No existe"], 404); // 404
responder(["error" => "Precio requerido"], 422); // 422
9 Endpoint GET /productos
Monta un mini route() que distinga el método. Para GET /productos devuelve
200 con la lista del modelo; para cualquier otro devuelve 405 («método no
permitido»).
Ver solución
$metodo = $_SERVER["REQUEST_METHOD"];
$ruta = parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH);
if ($ruta === "/productos" && $metodo === "GET") {
responder(Producto::todos($pdo));
} else {
responder(["error" => "Método no permitido"], 405);
}
10 Endpoint POST /productos
Para POST /productos, valida que los campos nombre y precio estén.
Si falta algo responde 422; si todo va bien, haz un INSERT con sentencia preparada
y responde 201 con el nuevo id.
Ver solución
if ($ruta === "/productos" && $metodo === "POST") {
$datos = json_decode(file_get_contents("php://input"), true);
if (empty($datos["nombre"]) || !isset($datos["precio"])) {
responder(["error" => "nombre y precio son obligatorios"], 422);
}
$stmt = $pdo->prepare("INSERT INTO productos (nombre, precio) VALUES (:n, :p)");
$stmt->execute(["n" => $datos["nombre"], "p" => $datos["precio"]]);
responder(["id" => $pdo->lastInsertId()], 201);
}
11 Endpoint DELETE /productos/:id
Para DELETE /productos/{id}, extrae el id de los segmentos de la URL y borra con una
sentencia preparada. Si rowCount() es 0 responde 404; si borra, responde
200 con un mensaje.
Ver solución
$segmentos = explode("/", trim($ruta, "/"));
$id = $segmentos[1] ?? null;
if ($metodo === "DELETE" && $segmentos[0] === "productos" && $id) {
$stmt = $pdo->prepare("DELETE FROM productos WHERE id = :id");
$stmt->execute(["id" => $id]);
if ($stmt->rowCount() === 0) {
responder(["error" => "Producto no existe"], 404);
}
responder(["ok" => true, "borrado" => $id]);
}
Sección C · Seguridad y autenticación
12 Validar entrada
Crea un endpoint POST /productos que valide: nombre no vacío (≤ 100 caracteres)
y precio numérico mayor que 0. Devuelve 422 con ["campo"=>...,
"error"=>...] en cada fallo.
Ver solución
$errores = [];
if (empty($datos["nombre"]) || mb_strlen($datos["nombre"]) > 100) {
$errores[] = ["campo" => "nombre", "error" => "Obligatorio, máx 100"];
}
if (!is_numeric($datos["precio"] ?? null) || $datos["precio"] <= 0) {
$errores[] = ["campo" => "precio", "error" => "Número mayor que 0"];
}
if ($errores) {
responder(["errores" => $errores], 422);
}
13 Cabecera Authorization
Escribe una función obtenerToken() que lea getallheaders(), busque
Authorization con prefijo Bearer y devuelva el token, o null si no
está bien formado. Prueba con una cabecera válida y una inválida.
Ver solución
function obtenerToken() {
$cabeceras = getallheaders();
$auth = $cabeceras["Authorization"] ?? "";
return str_starts_with($auth, "Bearer ")
? substr($auth, 7)
: null;
}
var_dump(obtenerToken()); // curl -H "Authorization: Bearer abc.123.xyz"
14 Proteger una ruta (unauthorized)
Envuelve una ruta protegida (GET /usuarios) para que responda 401 con JSON si
no hay token, y 200 con los datos si lo hay (aunque sea un token de prueba).
Ver solución
$token = obtenerToken();
if (!$token) {
responder(["error" => "Token requerido"], 401);
}
$usuarios = [["id" => 1, "nombre" => "Ana"], ["id" => 2, "nombre" => "Luis"]];
responder($usuarios);
Sección D · Testing y logs
15 Test unitario con assert
Define precioConIva($precio, $iva = 15) que devuelva el precio con el IVA aplicado
(redondeado a 2). Escribe una función assertSame y comprueba al menos 3 casos (incluido un
caso con IVA 0). Ejecuta php test.php.
Ver solución
// test.php
function precioConIva($precio, $iva = 15) {
return round($precio * (1 + $iva / 100), 2);
}
function assertSame($esp, $real, $etiqueta) {
if ($esp === $real) echo "✅ $etiqueta\n";
else echo "❌ $etiqueta (esp " . var_export($esp, true) . ", vi " . var_export($real, true) . ")\n";
}
assertSame(11.5, precioConIva(10), "IVA 15 sobre 10");
assertSame(115, precioConIva(100), "IVA 15 sobre 100");
assertSame(20, precioConIva(20, 0), "IVA 0");
16 Log de errores
En la ruta de crear un producto, envuelve el insert en try/catch. En el catch
registra el mensaje con error_log() y responde 500 genérico (sin mostrar detalles
internos al cliente).
Ver solución
try {
$stmt = $pdo->prepare(
"INSERT INTO productos (nombre, precio) VALUES (:n, :p)"
);
$stmt->execute(["n" => $datos["nombre"], "p" => $datos["precio"]]);
responder(["id" => $pdo->lastInsertId()], 201);
} catch (PDOException $e) {
error_log("Error al crear producto: " . $e->getMessage());
responder(["error" => "Error interno del servidor"], 500);
}
17 🏆 Proyecto integrador: «tienda-api»
Une TODO el bloque en una API REST de tienda dentro de php-b4/ con
arquitectura MVC:
- Estructura MVC con carpetas separadas (al menos
app/Controllers,app/Modelsyapp/Core). - Un front controller único en
public/index.phpcon cabecera JSON. - Un router que distinga método y ruta, y devuelva códigos de estado (
404en ruta inexistente). - Usa la clase
Productocomo modelo con PDO preparado. - Al menos GET (listar) y POST (crear) devolviendo JSON (201 al crear).
- Protege GET /usuarios exigiendo token Bearer (401 si falta).
- htmlspecialchars() o validación al devolver datos del usuario.
Este ejercicio es el reto del laboratorio del examen: la validación automática comprobará estos criterios
sobre el contenido de tus etiquetas <?php ... ?>.
📝 Examen global del Bloque 4
Dos partes: un test teórico autoevaluable (mínimo 70 %) y un reto práctico de autocorrección con checklist.
Parte B · Proyecto final: «tienda-api»
Construye el ejercicio 17 («tienda-api») sin mirar soluciones y valida tú mismo/a estos criterios. Mínimo para aprobar: 6 de 8 casillas.
Cumplidos: 0 / 8