🐘 Curso PHP · Nivel N4–N5 Avanzado / Experto

Bloque 4: MVC, APIs y proyecto final

El bloque que te convierte en backend profesional: estructurarás aplicaciones con la arquitectura MVC y un front controller, expondrás datos con APIs REST usando JSON, rutas, cabeceras y autenticación con JWT, y llevarás tu código a producción con testing, logs y despliegue. Terminarás integrando todo en un proyecto final tutelado.

⏱ ≈ 18 horas 📚 6 lecciones ✍️ 16 ejercicios guiados 📝 Proyecto final + examen global
Lección 1 · Teoría

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.

Modelo — trabaja con los datos: la base de datos, las consultas y la lógica de negocio (qué significa «añadir producto», calcúlar totales, etc.).
Vista — muestra los datos al usuario: plantillas HTML. Solo presenta; nunca consulta la BD directamente.
Controlador — el «director de orquesta»: recibe la petición, pide datos al modelo y se los entrega a la vista.
💡 Para responder la pregunta clave de arquitectura: «¿dónde va este código?» Piensa en el flujo: petición → Controlador (decide) → Modelo (datos) → Vista (presentación) → respuesta. Separar estos roles hace el código más fácil de mantener y de probar.

1.1 · Estructura de carpetas típica

estructura de un proyecto MVC
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
⚠️ La carpeta 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.

💡 No confundas: MVC organiza la aplicación en capas; el router decide qué código responde a cada URL. Trabajan juntos: el router llama al controlador correcto y éste orquesta el modelo y la vista.
Lección 2 · Teoría

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

leer la ruta desde la URL
$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

mini router en index.php
$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);
⚠️ Riesgo de seguridad: incluir el controlador con 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 definidas en un array
$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;
}
💡 Con una tabla de rutas tú decides exactamente qué URLs existen. Cualquier otra cosa responde 404. Es más seguro y más legible que el require automático de la sección anterior.
Lección 3 · Teoría

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

los verbos de una API REST
// 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

leer método y cuerpo en PHP
$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

responder en JSON
header("Content-Type: application/json; charset=utf-8");
echo json_encode([
    "ok"    => true,
    "datos" => $productos,
]);
⚠️ La cabecera 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

200 OK — devolución correcta
201 Created — se creó un recurso (POST exitoso)
400 Bad Request — datos inválidos del cliente
401 Unauthorized — no autenticado
403 Forbidden — sin permisos
404 Not Found — recurso inexistente
422 Unprocessable — validación fallida
500 Internal Server Error — error del servidor

En PHP se fijan con http_response_code($codigo).

💡 Usa el verbo y la ruta para expresar la intención, y los códigos de estado para expresar el resultado. Una API bien diseñada se entiende sin leer un manual.
Lección 4 · Teoría

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:

anatomía de un JWT (simplificado)
// 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.

⚠️ Un JWT se firma, no se cifra: cualquiera puede leer el payload (es base64), pero no puede alterarlo sin la clave. Nunca pongas datos sensibles en un JWT. Para middleware de firma puedes usar la librería firebase/php-jwt (o cuidar el algoritmo HMAC si lo implementas a mano).

4.2 · Leer el token de la cabecera Authorization

extraer el Bearer token
$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

login devuelve token; peticiones lo usan
// 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"]]);
💡 El token debe tener caducidad (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.
Lección 5 · Teoría

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:

mini-test sin frameworks
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");
💡 En producción real se usa un framework como PHPUnit. Aquí montamos la idea a mano para que veas qué se comprueba: entrada → salida esperada. Un test que no se puede ejecutar repetidamente no sirve.

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:

escribir en el log
$pedido = crearPedido($usuarioId, $productos);
error_log("Pedido creado id=$pedido->id para usuario $usuarioId");
⚠️ En producción nunca muestres errores al usuario; regístralos en un log y devuelve una respuesta genérica 500. Filtrar la petición con finally o un bloque catch que registre y responda 500 es una buena práctica.

5.3 · Despliegue a producción

1 · Copia los archivos (sin vendor ni archivos sensibles)
2 · Configura creencias con variables de entorno
3 · Apunta el DocumentRoot a public/
4 · Sube la BD con las migraciones
5 · Activa HTTPS y display_errors=Off
6 · Prueba con HTTPS y captura los logs

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

Lección 6 · Teoría

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/ — arquitectura completa
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

public/index.php — front controller
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));
💡 El front controller es un único punto de entrada: todas las peticiones pasan por él. Ahí pones las cabeceras globales (JSON), la sesión, el registro de logs y el htmlspecialchars global si es una app de vistas. Centralizar el «inicio» hace la app consistente.

6.3 · Criterios del proyecto final

√ Arquitectura MVC con carpetas separadas
√ Front controller único en public/index.php
√ Router con tabla de rutas
√ PDO con sentencias preparadas
√ API REST con JSON y códigos de estado
√ Autenticación JWT en rutas protegidas

Este proyecto es el reto del laboratorio del examen y se valida automáticamente.

Práctica guiada

✍️ 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/Models y app/Core).
  • Un front controller único en public/index.php con cabecera JSON.
  • Un router que distinga método y ruta, y devuelva códigos de estado (404 en ruta inexistente).
  • Usa la clase Producto como 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 ... ?>.

Evaluación del bloque

📝 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 A · Test teórico (aprueba con 11/15)

1 · En MVC, ¿quién se encarga de consultar la base de datos?
2 · ¿Qué es un «front controller»?
3 · ¿Cómo obtienes solo el camino de la URL (sin la query)?
4 · En REST, ¿qué verbo crea un recurso?
5 · ¿Qué cabecera indica que la respuesta es JSON?
6 · ¿Qué función convierte un array de PHP en JSON?
7 · ¿Cómo lees el body JSON de una petición?
8 · Con una ruta inexistente, ¿qué código devuelves?
9 · Un JWT…
10 · ¿En qué cabecera suele viajar el token JWT?
11 · Si una petición no está autenticada, la API responde…
12 · Para probar una API con distintos métodos se usa…
13 · ¿Qué es un test unitario?
14 · En producción, los errores deberían…
15 · ¿Qué carpeta debe ser la única accesible por el navegador?

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

🎉 ¿Superaste las dos partes? Enhorabuena: has terminado el curso de PHP. Ya estructuras aplicaciones con MVC, construyes APIs REST con JSON, rutas, cabeceras y autenticación JWT, y despliegas con testing y logs. Eres desarrollador/a backend de nivel profesional. 🐘🔥