Módulo 20: Proyecto final
Objetivos del módulo
- Integrar todo lo aprendido en una aplicación web completa: Biblioteca, un catálogo de libros con usuarios
- Reutilizar la arquitectura MVC del módulo 19 (router, contenedor, vistas, repositorios)
- Implementar registro y login seguros, CSRF, mensajes flash y validación de formularios
- Construir el CRUD de libros con PDO, paginación, búsqueda y subida de portadas
- Exponer una API REST JSON del mismo recurso con autenticación por token
- Escribir tests PHPUnit de las clases de dominio
- Repasar una checklist de seguridad y aprender a desplegar la aplicación
- Plantear ampliaciones y el salto a un framework profesional
1. El proyecto: Biblioteca
Vas a construir Biblioteca, una aplicación para gestionar un catálogo de libros:
| Funcionalidad | Módulos que aplicas |
|---|---|
| Registro, login y logout con contraseñas seguras y límite de intentos | 9, 16 |
| Listado público de libros con búsqueda y paginación | 15, 19 |
| Ficha de cada libro con su portada | 8, 19 |
| Alta, edición y borrado de libros (solo usuarios registrados) | 15, 19 |
| Validación de formularios, incluido el ISBN | 8, 12 |
| Protección CSRF y mensajes flash | 9, 16 |
| Subida segura de portadas | 8, 16 |
API REST JSON /api/libros con token Bearer | 17 |
| Tests unitarios del dominio | 18 |
| Errores y logs centralizados | 14 |
💡 Consejo: No intentes hacerlo todo de golpe. Sigue las fases de la sección 10: cada una termina con algo que funciona y que puedes probar. Haz un commit de Git al final de cada fase.
2. Estructura de carpetas
biblioteca/
├── composer.json
├── phpunit.xml
├── .env / .env.example
├── database/
│ └── biblioteca.sql
├── config/
│ ├── container.php
│ └── routes.php
├── public/
│ ├── index.php
│ ├── .htaccess
│ ├── css/app.css
│ └── portadas/ ← imágenes subidas (sin ejecución de PHP)
├── src/
│ ├── Core/ ← framework del módulo 19 + Session, Csrf, Auth, Contexto
│ ├── Domain/ ← Isbn, Libro, Usuario, Paginador (sin dependencias)
│ ├── Repository/ ← LibroRepository, UsuarioRepository, TokenRepository
│ ├── Service/ ← SubidaPortadas, LimitadorLogin
│ ├── Controller/ ← HomeController, AuthController, LibroController
│ │ └── Api/ ← LibroApiController, TokenApiController
│ └── helpers.php
├── views/
│ ├── layout.php
│ ├── partials/paginacion.php
│ ├── auth/login.php, registro.php
│ ├── libros/index.php, show.php, form.php
│ └── errors/error.php
├── tests/
│ └── Unit/Domain/IsbnTest.php, LibroTest.php, PaginadorTest.php
└── var/log/
composer.json:
{
"name": "curso/biblioteca",
"type": "project",
"require": {
"php": ">=8.5",
"ext-pdo": "*",
"ext-fileinfo": "*",
"vlucas/phpdotenv": "^5.6",
"monolog/monolog": "^3.0"
},
"require-dev": {
"phpunit/phpunit": "^13"
},
"autoload": {
"psr-4": { "App\\": "src/" },
"files": ["src/helpers.php"]
},
"autoload-dev": {
"psr-4": { "App\\Tests\\": "tests/" }
},
"scripts": {
"test": "phpunit",
"serve": "php -S localhost:8000 -t public public/index.php"
}
}
3. La base de datos
-- database/biblioteca.sql
CREATE DATABASE IF NOT EXISTS biblioteca CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE biblioteca;
CREATE TABLE usuarios (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
nombre VARCHAR(100) NOT NULL,
email VARCHAR(190) NOT NULL UNIQUE,
clave_hash VARCHAR(255) NOT NULL,
creado_en DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
CREATE TABLE libros (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
isbn CHAR(13) NOT NULL UNIQUE,
titulo VARCHAR(200) NOT NULL,
autor VARCHAR(150) NOT NULL,
anio SMALLINT NULL,
sinopsis TEXT NULL,
portada VARCHAR(100) NULL, -- nombre del fichero en public/portadas
creado_por INT UNSIGNED NOT NULL,
creado_en DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
actualizado_en DATETIME NULL,
FULLTEXT INDEX ft_busqueda (titulo, autor),
FOREIGN KEY (creado_por) REFERENCES usuarios(id)
) ENGINE=InnoDB;
CREATE TABLE api_tokens (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
usuario_id INT UNSIGNED NOT NULL,
token_hash CHAR(64) NOT NULL UNIQUE,
caduca_en DATETIME NOT NULL,
FOREIGN KEY (usuario_id) REFERENCES usuarios(id) ON DELETE CASCADE
) ENGINE=InnoDB;
CREATE TABLE intentos_login (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(190) NOT NULL,
ip VARCHAR(45) NOT NULL,
creado_en DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX (email, creado_en),
INDEX (ip, creado_en)
) ENGINE=InnoDB;
-- Usuario de la aplicación con permisos mínimos
CREATE USER IF NOT EXISTS 'biblioteca'@'localhost' IDENTIFIED BY 'cambia-esta-clave';
GRANT SELECT, INSERT, UPDATE, DELETE ON biblioteca.* TO 'biblioteca'@'localhost';
4. El dominio: clases puras y testeables
Las clases de src/Domain/ no dependen de la base de datos, de HTTP ni de la sesión. Solo contienen datos y reglas. Por eso son las más fáciles de probar.
Value object Isbn
📘 Concepto: Un value object es un objeto pequeño e inmutable que representa un valor con reglas propias (un ISBN, un email, un importe). Si existe un objeto
Isbn, es válido: la validación ocurre al crearlo y no hay que repetirla en ningún otro sitio.
<?php
declare(strict_types=1);
// src/Domain/Isbn.php
namespace App\Domain;
final readonly class Isbn implements \Stringable
{
private function __construct(public string $valor) {}
public static function desde(string $texto): self
{
$digitos = preg_replace('/[\s-]/', '', $texto); // "978-84-376-0494-7" → "9788437604947"
if (!preg_match('/^97[89]\d{10}$/', $digitos)) {
throw new \InvalidArgumentException('El ISBN debe tener 13 dígitos y empezar por 978 o 979');
}
if (!self::digitoControlCorrecto($digitos)) {
throw new \InvalidArgumentException('El dígito de control del ISBN no es correcto');
}
return new self($digitos);
}
private static function digitoControlCorrecto(string $isbn): bool
{
$suma = 0;
for ($i = 0; $i < 12; $i++) {
$suma += (int) $isbn[$i] * ($i % 2 === 0 ? 1 : 3); // pesos 1, 3, 1, 3...
}
return (10 - $suma % 10) % 10 === (int) $isbn[12];
}
/** 978-8437604947 → formato legible básico */
public function formateado(): string
{
return substr($this->valor, 0, 3) . '-' . substr($this->valor, 3);
}
public function __toString(): string
{
return $this->valor;
}
}
Entidad Libro
<?php
declare(strict_types=1);
// src/Domain/Libro.php
namespace App\Domain;
final readonly class Libro
{
public function __construct(
public ?int $id,
public Isbn $isbn,
public string $titulo,
public string $autor,
public ?int $anio = null,
public ?string $sinopsis = null,
public ?string $portada = null,
public ?int $creadoPor = null,
) {
if (mb_trim($titulo) === '' || mb_strlen($titulo) > 200) {
throw new \InvalidArgumentException('El título es obligatorio (máx. 200 caracteres)');
}
if (mb_trim($autor) === '') {
throw new \InvalidArgumentException('El autor es obligatorio');
}
if ($anio !== null && ($anio < 1450 || $anio > (int) date('Y') + 1)) {
throw new \DomainException('El año de publicación no es válido');
}
}
public static function desdeFila(array $f): self
{
return new self(
(int) $f['id'], Isbn::desde($f['isbn']), $f['titulo'], $f['autor'],
$f['anio'] !== null ? (int) $f['anio'] : null, $f['sinopsis'], $f['portada'], (int) $f['creado_por'],
);
}
public function conId(int $id): static
{
return clone($this, ['id' => $id]);
}
public function conPortada(?string $portada): static
{
return clone($this, ['portada' => $portada]);
}
public function esDe(int $usuarioId): bool
{
return $this->creadoPor === $usuarioId;
}
public function aArray(): array
{
return [
'id' => $this->id, 'isbn' => (string) $this->isbn, 'titulo' => $this->titulo,
'autor' => $this->autor, 'anio' => $this->anio, 'sinopsis' => $this->sinopsis,
'portada' => $this->portada !== null ? "/portadas/{$this->portada}" : null,
];
}
}
Paginador
<?php
declare(strict_types=1);
// src/Domain/Paginador.php
namespace App\Domain;
final readonly class Paginador
{
public int $totalPaginas;
public int $pagina;
public function __construct(public int $total, int $paginaPedida, public int $porPagina = 10)
{
if ($porPagina < 1) {
throw new \InvalidArgumentException('porPagina debe ser al menos 1');
}
$this->totalPaginas = max(1, (int) ceil($total / $porPagina));
$this->pagina = min(max(1, $paginaPedida), $this->totalPaginas);
}
public function offset(): int
{
return ($this->pagina - 1) * $this->porPagina;
}
public function hayAnterior(): bool
{
return $this->pagina > 1;
}
public function haySiguiente(): bool
{
return $this->pagina < $this->totalPaginas;
}
}
5. Piezas nuevas del núcleo: sesión, CSRF y autenticación
Partimos de las clases Request, Response, Router, View, Container y HttpException del módulo 19 y añadimos estas.
<?php
declare(strict_types=1);
// src/Core/Session.php
namespace App\Core;
final class Session
{
public function __construct()
{
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start([
'cookie_httponly' => true,
'cookie_secure' => ($_SERVER['HTTPS'] ?? '') === 'on',
'cookie_samesite' => 'Lax',
'use_strict_mode' => true,
]);
}
}
public function get(string $clave, mixed $porDefecto = null): mixed { return $_SESSION[$clave] ?? $porDefecto; }
public function set(string $clave, mixed $valor): void { $_SESSION[$clave] = $valor; }
public function quitar(string $clave): void { unset($_SESSION[$clave]); }
public function regenerar(): void { session_regenerate_id(true); }
/** Mensaje que se muestra una sola vez (patrón PRG, módulo 9) */
public function flash(string $tipo, string $mensaje): void
{
$_SESSION['_flash'][] = ['tipo' => $tipo, 'mensaje' => $mensaje];
}
/** @return list<array{tipo: string, mensaje: string}> */
public function leerFlash(): array
{
$mensajes = $_SESSION['_flash'] ?? [];
unset($_SESSION['_flash']);
return $mensajes;
}
}
<?php
declare(strict_types=1);
// src/Core/Csrf.php
namespace App\Core;
final class Csrf
{
public function __construct(private Session $session) {}
public function token(): string
{
$token = $this->session->get('_csrf');
if ($token === null) {
$token = bin2hex(random_bytes(32));
$this->session->set('_csrf', $token);
}
return $token;
}
public function validar(Request $request): void
{
$enviado = $request->body['_csrf'] ?? '';
if (!is_string($enviado) || !hash_equals($this->token(), $enviado)) {
throw new HttpException(403, 'La sesión ha caducado o el formulario no es válido. Recarga la página.');
}
}
}
<?php
declare(strict_types=1);
// src/Core/Auth.php
namespace App\Core;
use App\Domain\Usuario;
use App\Repository\UsuarioRepository;
// NoAutenticado es una excepción vacía en su propio fichero (PSR-4):
// src/Core/NoAutenticado.php → final class NoAutenticado extends \RuntimeException {}
final class Auth
{
private ?Usuario $usuario = null;
public function __construct(private Session $session, private UsuarioRepository $usuarios) {}
public function usuario(): ?Usuario
{
$id = $this->session->get('usuario_id');
return $this->usuario ??= ($id !== null ? $this->usuarios->buscarPorId($id) : null);
}
public function exigir(): Usuario
{
return $this->usuario() ?? throw new NoAutenticado();
}
public function iniciar(Usuario $usuario): void
{
$this->session->regenerar(); // Evita la fijación de sesión
$this->session->set('usuario_id', $usuario->id);
$this->usuario = $usuario;
}
public function cerrar(): void
{
$_SESSION = [];
session_regenerate_id(true);
$this->usuario = null;
}
}
💡 Consejo: La clase
Usuariodel dominio es unafinal readonly classconid,nombre,claveHash, yUsuarioRepositorysigue el mismo patrón queProductoRepositorydel módulo 19 (buscarPorId(),buscarPorEmail(),crear()). Escríbelas tú: es un buen ejercicio de repaso.
Un “contexto” común para los controladores
Para no repetir cuatro dependencias en cada controlador, las agrupamos:
<?php
declare(strict_types=1);
// src/Core/Contexto.php
namespace App\Core;
final readonly class Contexto
{
public function __construct(
public View $view,
public Session $session,
public Csrf $csrf,
public Auth $auth,
) {}
}
<?php
declare(strict_types=1);
// src/Core/Controller.php
namespace App\Core;
abstract class Controller
{
public function __construct(protected Contexto $ctx) {}
protected function render(string $plantilla, array $datos = [], int $status = 200): Response
{
// Variables disponibles en TODAS las vistas y en el layout
$comunes = [
'usuarioActual' => $this->ctx->auth->usuario(),
'flash' => $this->ctx->session->leerFlash(),
'csrf' => $this->ctx->csrf->token(),
];
return new Response($this->ctx->view->render($plantilla, [...$comunes, ...$datos]), $status);
}
protected function redirect(string $url, ?string $mensaje = null, string $tipo = 'success'): Response
{
if ($mensaje !== null) {
$this->ctx->session->flash($tipo, $mensaje);
}
return Response::redirect($url);
}
}
6. Registro y login
<?php
declare(strict_types=1);
// src/Controller/AuthController.php (extracto)
namespace App\Controller;
use App\Core\{Contexto, Controller, Request, Response};
use App\Repository\UsuarioRepository;
use App\Service\LimitadorLogin;
final class AuthController extends Controller
{
public function __construct(
Contexto $ctx,
private UsuarioRepository $usuarios,
private LimitadorLogin $limitador,
) {
parent::__construct($ctx);
}
public function registrar(Request $request): Response
{
$this->ctx->csrf->validar($request);
$nombre = mb_trim((string) $request->input('nombre', ''));
$email = mb_strtolower(mb_trim((string) $request->input('email', '')));
$clave = (string) $request->input('clave', '');
$errores = [];
if ($nombre === '') {
$errores['nombre'] = 'Indica tu nombre.';
}
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
$errores['email'] = 'El email no es válido.';
} elseif ($this->usuarios->buscarPorEmail($email) !== null) {
$errores['email'] = 'Ya existe una cuenta con ese email.';
}
if (mb_strlen($clave) < 12) {
$errores['clave'] = 'La contraseña debe tener al menos 12 caracteres.';
}
if ($errores !== []) {
return $this->render('auth/registro', compact('nombre', 'email', 'errores') + ['titulo' => 'Crear cuenta'], 422);
}
$usuario = $this->usuarios->crear($nombre, $email, password_hash($clave, PASSWORD_DEFAULT));
$this->ctx->auth->iniciar($usuario);
return $this->redirect('/libros', "¡Bienvenido, $nombre!");
}
public function login(Request $request): Response
{
$this->ctx->csrf->validar($request);
$email = mb_strtolower(mb_trim((string) $request->input('email', '')));
$clave = (string) $request->input('clave', '');
$ip = $request->server['REMOTE_ADDR'] ?? '0.0.0.0';
if ($this->limitador->estaBloqueado($email, $ip)) {
return $this->render('auth/login', [
'titulo' => 'Iniciar sesión', 'email' => $email,
'error' => 'Demasiados intentos. Espera 15 minutos.',
], 429);
}
$usuario = $this->usuarios->buscarPorEmail($email);
if ($usuario === null || !password_verify($clave, $usuario->claveHash)) {
$this->limitador->registrarFallo($email, $ip);
return $this->render('auth/login', [
'titulo' => 'Iniciar sesión', 'email' => $email,
'error' => 'Email o contraseña incorrectos.', // Mensaje genérico (módulo 16)
], 401);
}
if (password_needs_rehash($usuario->claveHash, PASSWORD_DEFAULT)) {
$this->usuarios->actualizarClave($usuario->id, password_hash($clave, PASSWORD_DEFAULT));
}
$this->limitador->limpiar($email);
$this->ctx->auth->iniciar($usuario);
$destino = (string) $request->input('volver', '/libros');
return $this->redirect(str_starts_with($destino, '/') && !str_starts_with($destino, '//') ? $destino : '/libros');
}
public function logout(Request $request): Response
{
$this->ctx->csrf->validar($request);
$this->ctx->auth->cerrar();
return $this->redirect('/', 'Has cerrado la sesión.');
}
}
💡 Consejo: La clase
LimitadorLogines la del módulo 16. Para el parámetrovolverhemos usado una comprobación sencilla (ruta relativa que no empieza por//); como mejora, sustitúyela por la funcióndestinoSeguro()conUri\WhatWg\Urlde ese mismo módulo.
7. El CRUD de libros
Repositorio con búsqueda y paginación
<?php
declare(strict_types=1);
// src/Repository/LibroRepository.php (extracto)
namespace App\Repository;
use App\Domain\{Isbn, Libro, Paginador};
use PDO;
final class LibroRepository
{
public function __construct(private PDO $pdo) {}
/** @return array{0: list<Libro>, 1: Paginador} */
public function buscarPaginado(string $texto, int $pagina, int $porPagina = 12): array
{
$patron = '%' . addcslashes($texto, '%_\\') . '%';
$stmt = $this->pdo->prepare('SELECT COUNT(*) FROM libros WHERE titulo LIKE :t OR autor LIKE :a');
$stmt->execute(['t' => $patron, 'a' => $patron]);
$paginador = new Paginador((int) $stmt->fetchColumn(), $pagina, $porPagina);
$stmt = $this->pdo->prepare(
'SELECT * FROM libros WHERE titulo LIKE :t OR autor LIKE :a
ORDER BY titulo LIMIT :limite OFFSET :desp'
);
$stmt->bindValue(':t', $patron);
$stmt->bindValue(':a', $patron);
$stmt->bindValue(':limite', $paginador->porPagina, PDO::PARAM_INT);
$stmt->bindValue(':desp', $paginador->offset(), PDO::PARAM_INT);
$stmt->execute();
return [array_map(Libro::desdeFila(...), $stmt->fetchAll()), $paginador];
}
public function buscarPorId(int $id): ?Libro
{
$stmt = $this->pdo->prepare('SELECT * FROM libros WHERE id = ?');
$stmt->execute([$id]);
$fila = $stmt->fetch();
return $fila ? Libro::desdeFila($fila) : null;
}
public function existeIsbn(Isbn $isbn, ?int $excepto = null): bool
{
$stmt = $this->pdo->prepare('SELECT COUNT(*) FROM libros WHERE isbn = ? AND id <> ?');
$stmt->execute([(string) $isbn, $excepto ?? 0]);
return (int) $stmt->fetchColumn() > 0;
}
public function guardar(Libro $l): Libro
{
$datos = [
'isbn' => (string) $l->isbn, 'titulo' => $l->titulo, 'autor' => $l->autor,
'anio' => $l->anio, 'sinopsis' => $l->sinopsis, 'portada' => $l->portada,
];
if ($l->id === null) {
$this->pdo->prepare(
'INSERT INTO libros (isbn, titulo, autor, anio, sinopsis, portada, creado_por)
VALUES (:isbn, :titulo, :autor, :anio, :sinopsis, :portada, :creado_por)'
)->execute([...$datos, 'creado_por' => $l->creadoPor]);
return $l->conId((int) $this->pdo->lastInsertId());
}
$this->pdo->prepare(
'UPDATE libros SET isbn = :isbn, titulo = :titulo, autor = :autor, anio = :anio,
sinopsis = :sinopsis, portada = :portada, actualizado_en = NOW() WHERE id = :id'
)->execute([...$datos, 'id' => $l->id]);
return $l;
}
public function borrar(int $id): void
{
$this->pdo->prepare('DELETE FROM libros WHERE id = ?')->execute([$id]);
}
}
Subida de portadas
<?php
declare(strict_types=1);
// src/Service/SubidaPortadas.php
namespace App\Service;
final class SubidaPortadas
{
private const array TIPOS = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'];
private const int MAXIMO = 2 * 1024 * 1024;
public function __construct(private string $directorio) {}
/** Devuelve el nombre del fichero guardado, o null si no se ha enviado ninguno */
public function guardar(array $fichero): ?string
{
if (($fichero['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) {
return null;
}
if ($fichero['error'] !== UPLOAD_ERR_OK || $fichero['size'] > self::MAXIMO) {
throw new \InvalidArgumentException('La portada no se pudo subir (máximo 2 MB).');
}
$mime = (new \finfo(FILEINFO_MIME_TYPE))->file($fichero['tmp_name']);
if (!isset(self::TIPOS[$mime]) || getimagesize($fichero['tmp_name']) === false) {
throw new \InvalidArgumentException('La portada debe ser una imagen JPG, PNG o WebP.');
}
$nombre = bin2hex(random_bytes(16)) . '.' . self::TIPOS[$mime];
if (!move_uploaded_file($fichero['tmp_name'], "{$this->directorio}/$nombre")) {
throw new \RuntimeException('No se pudo guardar la portada.');
}
return $nombre;
}
public function borrar(?string $nombre): void
{
if ($nombre !== null && is_file($ruta = "{$this->directorio}/" . basename($nombre))) {
unlink($ruta);
}
}
}
El controlador
<?php
declare(strict_types=1);
// src/Controller/LibroController.php (extracto)
namespace App\Controller;
use App\Core\{Contexto, Controller, HttpException, Request, Response};
use App\Domain\{Isbn, Libro};
use App\Repository\LibroRepository;
use App\Service\SubidaPortadas;
final class LibroController extends Controller
{
public function __construct(Contexto $ctx, private LibroRepository $libros, private SubidaPortadas $portadas)
{
parent::__construct($ctx);
}
public function index(Request $request): Response
{
$q = mb_trim((string) $request->input('q', ''));
[$libros, $paginador] = $this->libros->buscarPaginado($q, (int) $request->input('pagina', 1));
return $this->render('libros/index', compact('libros', 'paginador', 'q') + ['titulo' => 'Catálogo']);
}
public function store(Request $request): Response
{
$usuario = $this->ctx->auth->exigir(); // Si no hay sesión → NoAutenticado → /login
$this->ctx->csrf->validar($request);
[$libro, $errores] = $this->construirDesdeFormulario($request, creadoPor: $usuario->id);
if ($errores === []) {
try {
$libro = $libro->conPortada($this->portadas->guardar($request->files['portada'] ?? []));
} catch (\InvalidArgumentException $e) {
$errores['portada'] = $e->getMessage();
}
}
if ($errores !== []) {
return $this->render('libros/form', [
'titulo' => 'Nuevo libro', 'accion' => '/libros', 'datos' => $request->body, 'errores' => $errores,
], 422);
}
$libro = $this->libros->guardar($libro);
return $this->redirect("/libros/{$libro->id}", 'Libro añadido al catálogo.');
}
public function destroy(Request $request, int $id): Response
{
$usuario = $this->ctx->auth->exigir();
$this->ctx->csrf->validar($request);
$libro = $this->libros->buscarPorId($id) ?? throw HttpException::notFound('Libro no encontrado');
if (!$libro->esDe($usuario->id)) {
throw new HttpException(403, 'Solo quien añadió el libro puede borrarlo.'); // Control de acceso (A01)
}
$this->libros->borrar($id);
$this->portadas->borrar($libro->portada);
return $this->redirect('/libros', 'Libro eliminado.');
}
/** @return array{0: ?Libro, 1: array<string, string>} */
private function construirDesdeFormulario(Request $request, int $creadoPor, ?Libro $actual = null): array
{
$errores = [];
$isbn = null;
try {
$isbn = Isbn::desde((string) $request->input('isbn', ''));
if ($this->libros->existeIsbn($isbn, $actual?->id)) {
$errores['isbn'] = 'Ya hay un libro con ese ISBN.';
}
} catch (\InvalidArgumentException $e) {
$errores['isbn'] = $e->getMessage();
}
$anio = mb_trim((string) $request->input('anio', ''));
if ($anio !== '' && filter_var($anio, FILTER_VALIDATE_INT) === false) {
$errores['anio'] = 'El año debe ser un número.';
}
if ($errores !== []) {
return [null, $errores];
}
try {
$libro = new Libro(
id: $actual?->id,
isbn: $isbn,
titulo: mb_trim((string) $request->input('titulo', '')),
autor: mb_trim((string) $request->input('autor', '')),
anio: $anio !== '' ? (int) $anio : null,
sinopsis: mb_trim((string) $request->input('sinopsis', '')) ?: null,
portada: $actual?->portada,
creadoPor: $creadoPor,
);
return [$libro, []];
} catch (\InvalidArgumentException | \DomainException $e) {
return [null, ['general' => $e->getMessage()]];
}
}
// show(), create(), edit() y update() siguen el mismo esquema que en el módulo 19
}
⚠️ Importante: El formulario de alta debe llevar
enctype="multipart/form-data"para que la portada llegue en$_FILES, además del campo oculto<input type="hidden" name="_csrf" value="<?= e($csrf) ?>">. Y la carpetapublic/portadas/no debe permitir la ejecución de PHP (sección 11).
Rutas
<?php
declare(strict_types=1);
// config/routes.php
use App\Controller\{AuthController, HomeController, LibroController};
use App\Controller\Api\{LibroApiController, TokenApiController};
use App\Core\Router;
return function (Router $r): void {
$r->get('/', [HomeController::class, 'index']);
$r->get('/registro', [AuthController::class, 'formRegistro']);
$r->post('/registro', [AuthController::class, 'registrar']);
$r->get('/login', [AuthController::class, 'formLogin']);
$r->post('/login', [AuthController::class, 'login']);
$r->post('/logout', [AuthController::class, 'logout']);
$r->get('/libros', [LibroController::class, 'index']);
$r->get('/libros/nuevo', [LibroController::class, 'create']);
$r->post('/libros', [LibroController::class, 'store']);
$r->get('/libros/{id:\d+}', [LibroController::class, 'show']);
$r->get('/libros/{id:\d+}/editar', [LibroController::class, 'edit']);
$r->post('/libros/{id:\d+}', [LibroController::class, 'update']);
$r->post('/libros/{id:\d+}/borrar', [LibroController::class, 'destroy']);
// API REST (módulo 17)
$r->post('/api/token', [TokenApiController::class, 'crear']);
$r->get('/api/libros', [LibroApiController::class, 'index']);
$r->get('/api/libros/{id:\d+}', [LibroApiController::class, 'show']);
$r->post('/api/libros', [LibroApiController::class, 'store']);
$r->add('PUT', '/api/libros/{id:\d+}', [LibroApiController::class, 'update']);
$r->add('DELETE', '/api/libros/{id:\d+}', [LibroApiController::class, 'destroy']);
};
La vista del listado con paginación
<?php
// views/libros/index.php
/** @var list<App\Domain\Libro> $libros @var App\Domain\Paginador $paginador @var string $q */
?>
<div class="d-flex justify-content-between align-items-center mb-3">
<h1>Catálogo <small class="text-muted fs-6"><?= $paginador->total ?> libros</small></h1>
<?php if ($usuarioActual): ?>
<a href="/libros/nuevo" class="btn btn-primary">Añadir libro</a>
<?php endif; ?>
</div>
<form method="get" class="d-flex gap-2 mb-4">
<input class="form-control" type="search" name="q" value="<?= e($q) ?>" placeholder="Título o autor">
<button class="btn btn-outline-secondary">Buscar</button>
</form>
<div class="row row-cols-2 row-cols-md-4 g-3">
<?php foreach ($libros as $libro): ?>
<div class="col">
<a href="/libros/<?= $libro->id ?>" class="card h-100 text-decoration-none">
<img src="<?= e($libro->portada ? "/portadas/{$libro->portada}" : '/css/sin-portada.svg') ?>"
class="card-img-top" alt="Portada de <?= e($libro->titulo) ?>" loading="lazy">
<div class="card-body">
<h2 class="h6 card-title"><?= e($libro->titulo) ?></h2>
<p class="card-text small text-muted"><?= e($libro->autor) ?></p>
</div>
</a>
</div>
<?php endforeach; ?>
</div>
<?php if ($paginador->totalPaginas > 1): ?>
<nav class="mt-4">
<ul class="pagination justify-content-center">
<?php for ($i = 1; $i <= $paginador->totalPaginas; $i++): ?>
<li class="page-item <?= $i === $paginador->pagina ? 'active' : '' ?>">
<a class="page-link" href="?<?= e(http_build_query(['q' => $q, 'pagina' => $i])) ?>"><?= $i ?></a>
</li>
<?php endfor; ?>
</ul>
</nav>
<?php endif; ?>
En el layout se muestran los mensajes flash y el botón de cerrar sesión (que es un formulario POST con token):
<?php foreach ($flash ?? [] as $f): ?>
<div class="alert alert-<?= e($f['tipo']) ?> alert-dismissible fade show">
<?= e($f['mensaje']) ?>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Cerrar"></button>
</div>
<?php endforeach; ?>
<?php if ($usuarioActual): ?>
<form method="post" action="/logout" class="d-inline">
<input type="hidden" name="_csrf" value="<?= e($csrf) ?>">
<button class="btn btn-sm btn-outline-light">Salir (<?= e($usuarioActual->nombre) ?>)</button>
</form>
<?php endif; ?>
8. La API REST de libros
La API reutiliza el mismo repositorio y las mismas clases de dominio que la web: solo cambia la forma de entrada (JSON) y de salida (JSON).
<?php
declare(strict_types=1);
// src/Controller/Api/LibroApiController.php (extracto)
namespace App\Controller\Api;
use App\Core\{HttpException, Request, Response};
use App\Domain\{Isbn, Libro};
use App\Repository\{LibroRepository, TokenRepository};
final class LibroApiController
{
public function __construct(private LibroRepository $libros, private TokenRepository $tokens) {}
public function index(Request $request): Response
{
[$libros, $p] = $this->libros->buscarPaginado(
(string) $request->input('q', ''),
(int) $request->input('pagina', 1),
min(50, max(1, (int) $request->input('por_pagina', 12))),
);
return Response::json([
'datos' => array_map(fn(Libro $l) => $l->aArray(), $libros),
'meta' => ['pagina' => $p->pagina, 'total_paginas' => $p->totalPaginas, 'total' => $p->total],
]);
}
public function show(Request $request, int $id): Response
{
$libro = $this->libros->buscarPorId($id) ?? throw HttpException::notFound("No existe el libro $id");
return Response::json($libro->aArray());
}
public function store(Request $request): Response
{
$usuarioId = $this->usuarioDelToken($request);
try {
$datos = json_decode(file_get_contents('php://input'), true, 16, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
throw new HttpException(400, 'El cuerpo no es un JSON válido');
}
if (!is_array($datos)) {
throw new HttpException(400, 'Se esperaba un objeto JSON');
}
try {
$libro = new Libro(
id: null,
isbn: Isbn::desde((string) ($datos['isbn'] ?? '')),
titulo: (string) ($datos['titulo'] ?? ''),
autor: (string) ($datos['autor'] ?? ''),
anio: isset($datos['anio']) ? (int) $datos['anio'] : null,
sinopsis: $datos['sinopsis'] ?? null,
creadoPor: $usuarioId,
);
} catch (\InvalidArgumentException | \DomainException $e) {
throw new HttpException(422, $e->getMessage());
}
if ($this->libros->existeIsbn($libro->isbn)) {
throw new HttpException(409, 'Ya existe un libro con ese ISBN');
}
$libro = $this->libros->guardar($libro);
return Response::json($libro->aArray(), 201)->withHeader('Location', "/api/libros/{$libro->id}");
}
private function usuarioDelToken(Request $request): int
{
$cabecera = $request->server['HTTP_AUTHORIZATION'] ?? '';
if (!preg_match('/^Bearer\s+([a-f0-9]{64})$/', $cabecera, $m)) {
throw new HttpException(401, 'Falta el token Bearer');
}
return $this->tokens->usuarioValido(hash('sha256', $m[1])) ?? throw new HttpException(401, 'Token no válido o caducado');
}
}
TokenApiController::crear() recibe {"email": "...", "clave": "..."}, comprueba la contraseña con password_verify() (y con el limitador de intentos), genera un token con bin2hex(random_bytes(32)), guarda su hash SHA-256 en api_tokens con una caducidad de 30 días y lo devuelve una sola vez.
En el front controller, los errores de las rutas /api/ se devuelven en formato Problem Details (módulo 17) en lugar de con la página HTML:
<?php
// public/index.php (fragmento del manejo de errores)
} catch (NoAutenticado) {
$response = Response::redirect('/login?volver=' . rawurlencode($request->path));
} catch (HttpException $e) {
$response = str_starts_with($request->path, '/api/')
? Response::json(['type' => 'about:blank', 'title' => $e->getMessage(), 'status' => $e->status], $e->status)
->withHeader('Content-Type', 'application/problem+json')
: new Response($container->get(View::class)->render('errors/error', [
'titulo' => "Error {$e->status}", 'status' => $e->status, 'mensaje' => $e->getMessage(),
]), $e->status);
}
Prueba la API con un fichero pruebas.http:
@base = http://localhost:8000/api
### Obtener un token
POST {{base}}/token
Content-Type: application/json
{ "email": "ana@example.com", "clave": "una-clave-bien-larga" }
### Crear un libro
POST {{base}}/libros
Content-Type: application/json
Authorization: Bearer PEGA_AQUI_EL_TOKEN
{ "isbn": "978-84-376-0494-7", "titulo": "Cien años de soledad", "autor": "Gabriel García Márquez", "anio": 1967 }
### Buscar
GET {{base}}/libros?q=garcía&por_pagina=5
9. Tests del dominio
<?php
declare(strict_types=1);
// tests/Unit/Domain/IsbnTest.php
namespace App\Tests\Unit\Domain;
use App\Domain\Isbn;
use PHPUnit\Framework\Attributes\{CoversClass, DataProvider};
use PHPUnit\Framework\TestCase;
#[CoversClass(Isbn::class)]
final class IsbnTest extends TestCase
{
public static function validos(): array
{
return [
'sin guiones' => ['9788437604947', '9788437604947'],
'con guiones' => ['978-84-376-0494-7', '9788437604947'],
'con espacios' => ['978 0 13 468599 1', '9780134685991'],
];
}
public static function invalidos(): array
{
return [
'dígito de control erróneo' => ['9788437604940'],
'demasiado corto' => ['978843760494'],
'prefijo no válido' => ['1234567890128'],
'con letras' => ['97884376O4947'],
'vacío' => [''],
];
}
#[DataProvider('validos')]
public function testAceptaIsbnValidosYLosNormaliza(string $entrada, string $esperado): void
{
$this->assertSame($esperado, (string) Isbn::desde($entrada));
}
#[DataProvider('invalidos')]
public function testRechazaIsbnInvalidos(string $entrada): void
{
$this->expectException(\InvalidArgumentException::class);
Isbn::desde($entrada);
}
}
<?php
declare(strict_types=1);
// tests/Unit/Domain/PaginadorTest.php
namespace App\Tests\Unit\Domain;
use App\Domain\Paginador;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
#[CoversClass(Paginador::class)]
final class PaginadorTest extends TestCase
{
public function testCalculaPaginasYDesplazamiento(): void
{
$p = new Paginador(total: 25, paginaPedida: 3, porPagina: 10);
$this->assertSame(3, $p->totalPaginas);
$this->assertSame(20, $p->offset());
$this->assertTrue($p->hayAnterior());
$this->assertFalse($p->haySiguiente());
}
public function testCorrigePaginasFueraDeRango(): void
{
$this->assertSame(1, (new Paginador(25, -4))->pagina);
$this->assertSame(3, (new Paginador(25, 99))->pagina);
}
public function testSinResultadosHayUnaPagina(): void
{
$p = new Paginador(0, 1);
$this->assertSame(1, $p->totalPaginas);
$this->assertSame(0, $p->offset());
}
}
<?php
declare(strict_types=1);
// tests/Unit/Domain/LibroTest.php
namespace App\Tests\Unit\Domain;
use App\Domain\{Isbn, Libro};
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\UsesClass;
use PHPUnit\Framework\TestCase;
#[CoversClass(Libro::class)]
#[UsesClass(Isbn::class)]
final class LibroTest extends TestCase
{
private function libro(array $cambios = []): Libro
{
$datos = [
'id' => null, 'isbn' => Isbn::desde('9788437604947'), 'titulo' => 'Cien años de soledad',
'autor' => 'Gabriel García Márquez', 'anio' => 1967, 'creadoPor' => 7,
];
return new Libro(...[...$datos, ...$cambios]);
}
public function testConIdDevuelveUnaCopiaSinModificarElOriginal(): void
{
$original = $this->libro();
$copia = $original->conId(42);
$this->assertNull($original->id);
$this->assertSame(42, $copia->id);
$this->assertSame($original->titulo, $copia->titulo);
}
public function testSabeQuienLoCreo(): void
{
$this->assertTrue($this->libro()->esDe(7));
$this->assertFalse($this->libro()->esDe(8));
}
public function testRechazaTituloVacio(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->libro(['titulo' => ' ']);
}
public function testRechazaAniosImposibles(): void
{
$this->expectException(\DomainException::class);
$this->libro(['anio' => 1200]);
}
}
composer test
# OK (15 tests, 23 assertions)
10. Plan de trabajo por fases
| Fase | Objetivo | Resultado comprobable |
|---|---|---|
| 1 | Copiar el núcleo del módulo 19, composer.json, .env, base de datos | La página de inicio se ve con el layout |
| 2 | Dominio (Isbn, Libro, Paginador) y sus tests | composer test en verde |
| 3 | Session, Csrf, Auth, registro y login | Te registras, entras y sales; el menú cambia |
| 4 | Listado con búsqueda y paginación | Con 30 libros de prueba, la paginación funciona |
| 5 | Alta, edición y borrado con validación, flash y CSRF | Errores junto a cada campo; mensajes tras guardar |
| 6 | Subida de portadas | Las imágenes se ven; un .php renombrado se rechaza |
| 7 | API REST y token | Todas las peticiones del pruebas.http responden lo esperado |
| 8 | Cabeceras de seguridad, logs con Monolog, repaso de la checklist | Nada en la checklist queda sin marcar |
| 9 | Despliegue | La aplicación funciona en un dominio con HTTPS |
11. Checklist de seguridad del proyecto
| Comprobación | Dónde |
|---|---|
El servidor web apunta a public/; .env, src/, vendor/, var/ no son accesibles | Configuración del servidor |
display_errors = Off en producción; errores al log con Monolog | php.ini, index.php |
Todas las consultas con datos usan prepare() | Repositorios |
Todas las variables de las vistas pasan por e() | views/ |
Todos los formularios POST llevan _csrf y se valida | Csrf::validar() en cada acción POST |
Contraseñas con password_hash(); rehash en el login | AuthController |
session_regenerate_id(true) al iniciar y cerrar sesión | Auth |
Cookies de sesión HttpOnly, Secure (con HTTPS) y SameSite=Lax | Session |
| Límite de intentos de login y mensaje de error genérico | LimitadorLogin |
| Solo el creador puede editar o borrar un libro | LibroController |
| Portadas: MIME real, 2 MB máximo, nombre aleatorio, carpeta sin PHP | SubidaPortadas, servidor |
| Tokens de API guardados como hash y con caducidad | api_tokens |
| Redirecciones tras login solo a rutas internas | AuthController::login() |
Cabeceras CSP, nosniff, frame-ancestors, Referrer-Policy, HSTS | index.php o servidor |
composer audit sin avisos; PHP 8.5 con soporte | CI y servidor |
Para impedir la ejecución de PHP en public/portadas/ con Apache, añade allí un .htaccess:
# public/portadas/.htaccess
<FilesMatch "\.(php|phtml|phar)$">
Require all denied
</FilesMatch>
Options -Indexes
12. Despliegue
Opción A: hosting compartido
- Comprueba en el panel que puedes elegir PHP 8.5 y que tienes acceso SSH (muy recomendable).
- Sube el proyecto fuera de la carpeta pública (por ejemplo,
~/biblioteca) y apunta el dominio o subdominio a~/biblioteca/public. Si el hosting no permite cambiar la raíz, coloca el contenido depublic/enpublic_html/y ajusta las rutas deindex.phphacia la carpeta del proyecto. - Instala las dependencias sin las de desarrollo y con el autoload optimizado:
composer install --no-dev --optimize-autoloader
- Crea la base de datos desde el panel, importa
database/biblioteca.sqlcon phpMyAdmin y crea el.envde producción (APP_ENV=prod,APP_DEBUG=false, credenciales reales). - Da permisos de escritura a
var/log/ypublic/portadas/. - Activa el certificado HTTPS gratuito (Let’s Encrypt) desde el panel y fuerza la redirección a HTTPS.
Opción B: VPS con Nginx y PHP-FPM
En un servidor Linux (por ejemplo, Ubuntu) instalas Nginx, PHP-FPM 8.5 con las extensiones necesarias (pdo_mysql, mbstring, intl, fileinfo), MariaDB y Composer. Nginx sirve los ficheros estáticos y pasa las peticiones PHP a PHP-FPM (FastCGI Process Manager), un conjunto de procesos PHP siempre en marcha.
# /etc/nginx/sites-available/biblioteca
server {
listen 80;
server_name biblioteca.ejemplo.es;
root /var/www/biblioteca/public;
client_max_body_size 3M; # Un poco más que el límite de las portadas
location / {
try_files $uri /index.php$is_args$args;
}
# Solo se ejecuta el front controller
location ~ ^/index\.php(/|$) {
fastcgi_pass unix:/run/php/php8.5-fpm.sock;
fastcgi_split_path_info ^(.+\.php)(/.*)$;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
internal;
}
# Cualquier otro .php (incluidos los de /portadas) no se ejecuta
location ~ \.php$ {
return 404;
}
}
sudo ln -s /etc/nginx/sites-available/biblioteca /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d biblioteca.ejemplo.es # Certificado HTTPS con Let's Encrypt
💡 Consejo: En producción activa OPcache (
opcache.enable=1), que guarda el código PHP ya compilado en memoria y multiplica el rendimiento. Y automatiza el despliegue: ungit pull+composer install --no-dev -oen un script, o un workflow de GitHub Actions que lo ejecute tras pasar los tests.
13. Propuestas de ampliación
Cuando tengas la versión básica funcionando, elige alguna de estas mejoras:
- Préstamos: tabla
prestamos(libro, usuario, fecha de préstamo, fecha de devolución), con las excepciones de dominio del ejercicio 4 del módulo 14 (LibroYaPrestado,LimitePrestamosAlcanzado) y una transacción al prestar. - Roles: columna
rol(lector,bibliotecario,admin) en un enum de PHP, y permisos según el rol. - Recuperación de contraseña con token de un solo uso y caducidad (módulo 16).
- Búsqueda a texto completo con el índice
FULLTEXT(MATCH(titulo, autor) AGAINST(:q)). - Integración con una API externa: rellenar automáticamente título, autor y portada a partir del ISBN usando una API pública de libros (por ejemplo, Open Library) con Guzzle.
- Cliente JavaScript que consuma tu API con
fetch(módulo 17). - Tests de integración de los repositorios con SQLite en memoria y un workflow de GitHub Actions.
- Migrar a Symfony: reescribe la aplicación con Symfony 8.1, Doctrine ORM 3 (entidades
LibroyUsuariocon atributos#[ORM\Entity]), Twig, el componente Security (login,#[IsGranted]), formularios con validación y#[MapRequestPayload]para la API. Comprobarás que casi todo lo que has construido a mano tiene su equivalente, más completo y probado. Tienes el camino detallado en el Curso de Symfony.
14. Ejercicios
Ejercicio 1: Completar las piezas que faltan
Implementa las clases que el módulo solo describe: Usuario, UsuarioRepository (con buscarPorId(), buscarPorEmail(), crear() y actualizarClave()), TokenRepository (con crear() y usuarioValido()) y TokenApiController. Registra en config/container.php las fábricas de PDO, View, SubidaPortadas (con la ruta de public/portadas) y el logger de Monolog.
Ejercicio 2: Edición de libros con cambio de portada
Implementa edit() y update() en LibroController. Si se sube una portada nueva, la anterior debe borrarse del disco después de guardar en la base de datos. Añade una casilla “Quitar portada”. Solo el creador del libro puede editarlo (403 en caso contrario).
Ejercicio 3: Tests de integración
Escribe un test de integración para LibroRepository con SQLite en memoria (adapta el SQL de creación de tablas) que compruebe la búsqueda por título y por autor, la paginación y existeIsbn() con y sin el parámetro $excepto.
Ejercicio 4: API completa
Completa update() (PUT) y destroy() (DELETE) en LibroApiController, respetando que solo el creador del libro puede modificarlo. Añade al pruebas.http todas las peticiones de la API, incluidas las que deben fallar (sin token, token caducado, ISBN duplicado, libro de otro usuario), y anota los códigos de estado obtenidos.
Ejercicio 5: Desplegar
Despliega la aplicación en un hosting o VPS siguiendo la sección 12. Comprueba con las herramientas de desarrollo del navegador que la cookie de sesión tiene Secure, HttpOnly y SameSite, que las cabeceras de seguridad están presentes y que https://tu-dominio/.env y https://tu-dominio/portadas/prueba.php devuelven un error.
Resumen
| Pieza | Responsabilidad |
|---|---|
public/index.php | Arranque, .env, manejo global de errores (HTML o Problem Details) |
Router + config/routes.php | URLs web y de la API |
Container + config/container.php | Construir servicios con autowiring |
Domain/ (Isbn, Libro, Paginador) | Reglas de negocio puras y testeadas |
Repository/ | Todo el SQL, con consultas preparadas |
Session, Csrf, Auth | Estado del usuario, flash, protección CSRF y login |
SubidaPortadas, LimitadorLogin | Servicios de seguridad reutilizables |
Controller/ y Controller/Api/ | Coordinar petición, dominio y respuesta |
views/ | HTML con e() en cada dato |
tests/ | PHPUnit sobre el dominio (y, como ampliación, los repositorios) |
| Despliegue | Raíz en public/, composer install --no-dev -o, HTTPS, OPcache |
¡Enhorabuena! Has recorrido PHP desde el primer echo hasta una aplicación web completa, segura, probada y desplegada. A partir de aquí, el siguiente paso natural es un framework profesional como Symfony o Laravel: ya entiendes qué hacen por dentro.