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 Usuario del dominio es una final readonly class con id, nombre, email y claveHash, y UsuarioRepository sigue el mismo patrón que ProductoRepository del 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 LimitadorLogin es la del módulo 16. Para el parámetro volver hemos usado una comprobación sencilla (ruta relativa que no empieza por //); como mejora, sustitúyela por la función destinoSeguro() con Uri\WhatWg\Url de 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 carpeta public/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

  1. Comprueba en el panel que puedes elegir PHP 8.5 y que tienes acceso SSH (muy recomendable).
  2. 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 de public/ en public_html/ y ajusta las rutas de index.php hacia la carpeta del proyecto.
  3. Instala las dependencias sin las de desarrollo y con el autoload optimizado:
composer install --no-dev --optimize-autoloader
  1. Crea la base de datos desde el panel, importa database/biblioteca.sql con phpMyAdmin y crea el .env de producción (APP_ENV=prod, APP_DEBUG=false, credenciales reales).
  2. Da permisos de escritura a var/log/ y public/portadas/.
  3. 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: un git pull + composer install --no-dev -o en 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:

  1. 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.
  2. Roles: columna rol (lector, bibliotecario, admin) en un enum de PHP, y permisos según el rol.
  3. Recuperación de contraseña con token de un solo uso y caducidad (módulo 16).
  4. Búsqueda a texto completo con el índice FULLTEXT (MATCH(titulo, autor) AGAINST(:q)).
  5. 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.
  6. Cliente JavaScript que consuma tu API con fetch (módulo 17).
  7. Tests de integración de los repositorios con SQLite en memoria y un workflow de GitHub Actions.
  8. Migrar a Symfony: reescribe la aplicación con Symfony 8.1, Doctrine ORM 3 (entidades Libro y Usuario con 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.