Saltar al contenido
proyecto.php · devschool

Proyecto PHP: libro de visitas con MySQL y PDO

Lección 26 de 26 · 16 min de lectura · Actualizado el

En esta lección
  1. Qué vamos a construir
  2. Paso 1: la estructura de carpetas
  3. Paso 2: la base de datos
  4. Paso 3: configuración y conexión
  5. Paso 4: las funciones
  6. Paso 5: la página completa
  7. Paso 6: los estilos
  8. Paso 7: probarlo
  9. Ideas para ampliarlo
  10. Errores frecuentes
  11. Resumen

Ha llegado el momento de juntar lo que has aprendido en el curso en una aplicación completa. Vas a construir el libro de visitas de un refugio de montaña: los senderistas dejan su nombre y un mensaje, y la página muestra los mensajes más recientes, cinco por página. Parece sencillo, pero toca todo lo importante de una web con PHP: formularios, validación, base de datos, seguridad y organización del código.

Qué vamos a construir

Antes de escribir código, decide qué debe hacer la aplicación. Esta lista es la especificación del proyecto:

  1. Un formulario con nombre (obligatorio), email (opcional, no se publica) y mensaje (obligatorio).
  2. Validación en el servidor, con los errores junto a cada campo y sin perder lo que había escrito el usuario.
  3. Los mensajes válidos se guardan en una tabla de MySQL usando PDO con consultas preparadas.
  4. Tras guardar, se redirige a la misma página con un aviso de “gracias” (patrón PRG: Post/Redirect/Get), para que recargar no duplique el mensaje.
  5. Debajo del formulario, la lista de mensajes del más nuevo al más antiguo, escapados con htmlspecialchars para evitar XSS.
  6. Paginación: cinco mensajes por página, con enlaces 1, 2, 3…
  7. Protección CSRF con un token en la sesión.

Todas las piezas las has visto en lecciones anteriores: Formularios GET y POST, Validar y sanear, Sesiones y cookies, MySQL con PDO y Seguridad. Aquí aprenderás a combinarlas.

Paso 1: la estructura de carpetas

Una buena práctica es separar lo que el navegador puede pedir directamente de lo que no. Solo la carpeta public/ es visible desde la web; la configuración (con la contraseña de la base de datos) y las funciones quedan fuera:

libro-visitas/
├── public/              ← la raíz web (lo único accesible por URL)
│   ├── index.php        ← la página: formulario + listado
│   └── estilos.css
├── src/
│   ├── config.php       ← datos de conexión
│   ├── conexion.php     ← crea el objeto PDO
│   └── funciones.php    ← validación, consultas, CSRF, paginación
└── sql/
    └── esquema.sql      ← creación de la base de datos

Para probarlo en local, entra en la carpeta del proyecto y arranca el servidor integrado apuntando a public/:

php -S localhost:8000 -t public

Con XAMPP, puedes copiar la carpeta en htdocs y abrir http://localhost/libro-visitas/public/. En un hosting real configurarías public/ como raíz del dominio.

Paso 2: la base de datos

Crea la base de datos y la tabla desde phpMyAdmin (pestaña SQL) o desde la consola de MySQL con este archivo, sql/esquema.sql:

CREATE DATABASE IF NOT EXISTS libro_visitas
  CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

USE libro_visitas;

CREATE TABLE IF NOT EXISTS mensajes (
  id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  nombre VARCHAR(60) NOT NULL,
  email VARCHAR(120) NULL,
  mensaje TEXT NOT NULL,
  creado_en DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

Algunas decisiones de diseño:

  • utf8mb4 admite cualquier carácter, incluidas tildes, eñes y emojis. El antiguo utf8 de MySQL no los admite todos.
  • email admite NULL porque es opcional: guardaremos NULL en vez de una cadena vacía, que es la forma correcta de decir “no hay dato”.
  • creado_en se rellena sola con DEFAULT CURRENT_TIMESTAMP, así PHP no tiene que enviar la fecha.
  • Las longitudes de VARCHAR coinciden con las que validaremos en PHP. Si las reglas no coinciden, MySQL podría rechazar o recortar datos.

Tienes más sobre tipos y restricciones en Crear tablas.

Paso 3: configuración y conexión

src/config.php devuelve un array con los datos que cambian de un ordenador a otro:

<?php
return [
    'dsn'      => 'mysql:host=localhost;dbname=libro_visitas;charset=utf8mb4',
    'usuario'  => 'root',
    'clave'    => '',
    'por_pagina' => 5,
];

Un archivo que hace return de un valor se recoge así: $config = require 'config.php';. Es una forma limpia de tener la configuración separada del código. En un proyecto con Composer podrías leer estos datos de un .env, como viste en Composer.

src/conexion.php crea la conexión con las opciones recomendadas:

<?php
declare(strict_types=1);

function conectar(array $config): PDO
{
    return new PDO($config['dsn'], $config['usuario'], $config['clave'], [
        PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES   => false,
    ]);
}
  • ERRMODE_EXCEPTION: cualquier error de SQL lanza una excepción en vez de fallar en silencio.
  • FETCH_ASSOC: las filas llegan como arrays asociativos ($fila['nombre']).
  • EMULATE_PREPARES => false: las consultas preparadas las hace el propio MySQL, y los números llegan como números.

Paso 4: las funciones

Toda la lógica va en src/funciones.php, en funciones pequeñas con un único trabajo. Así index.php queda legible y puedes probar cada función por separado.

Escapar y validar

<?php
declare(strict_types=1);

function e(?string $texto): string
{
    return htmlspecialchars($texto ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

function validarMensaje(array $entrada): array
{
    $datos = [
        'nombre'  => trim($entrada['nombre'] ?? ''),
        'email'   => trim($entrada['email'] ?? ''),
        'mensaje' => trim($entrada['mensaje'] ?? ''),
    ];
    $errores = [];

    $largo = mb_strlen($datos['nombre']);
    if ($largo < 2 || $largo > 60) {
        $errores['nombre'] = 'El nombre debe tener entre 2 y 60 caracteres.';
    }

    if ($datos['email'] !== '' && filter_var($datos['email'], FILTER_VALIDATE_EMAIL) === false) {
        $errores['email'] = 'El email no es válido (puedes dejarlo vacío).';
    }

    $largo = mb_strlen($datos['mensaje']);
    if ($largo < 5 || $largo > 1000) {
        $errores['mensaje'] = 'El mensaje debe tener entre 5 y 1000 caracteres.';
    }

    return [$datos, $errores];
}

[$datos, $errores] = validarMensaje(['nombre' => 'L', 'email' => 'lucia@', 'mensaje' => '']);
print_r($errores);
// Array
// (
//     [nombre] => El nombre debe tener entre 2 y 60 caracteres.
//     [email] => El email no es válido (puedes dejarlo vacío).
//     [mensaje] => El mensaje debe tener entre 5 y 1000 caracteres.
// )

validarMensaje() devuelve dos cosas: los datos ya limpios (para guardarlos o repintarlos en el formulario) y el array de errores. Con [$datos, $errores] = ... las recoges en dos variables a la vez (desestructuración de arrays).

Guardar y leer mensajes

<?php
function guardarMensaje(PDO $pdo, array $datos): int
{
    $sql = 'INSERT INTO mensajes (nombre, email, mensaje) VALUES (:nombre, :email, :mensaje)';
    $consulta = $pdo->prepare($sql);
    $consulta->execute([
        'nombre'  => $datos['nombre'],
        'email'   => $datos['email'] === '' ? null : $datos['email'],
        'mensaje' => $datos['mensaje'],
    ]);
    return (int) $pdo->lastInsertId();
}

function contarMensajes(PDO $pdo): int
{
    return (int) $pdo->query('SELECT COUNT(*) FROM mensajes')->fetchColumn();
}

function obtenerMensajes(PDO $pdo, int $limite, int $desplazamiento): array
{
    $consulta = $pdo->prepare(
        'SELECT id, nombre, mensaje, creado_en FROM mensajes
         ORDER BY id DESC LIMIT :limite OFFSET :desplazamiento'
    );
    $consulta->bindValue('limite', $limite, PDO::PARAM_INT);
    $consulta->bindValue('desplazamiento', $desplazamiento, PDO::PARAM_INT);
    $consulta->execute();
    return $consulta->fetchAll();
}

Los datos del usuario nunca se pegan dentro del SQL: van como parámetros (:nombre) y PDO los envía aparte, así que una comilla en el mensaje no puede romper la consulta (inyección SQL). En obtenerMensajes() usamos bindValue() con PDO::PARAM_INT porque LIMIT y OFFSET deben ser enteros: si los pasas como texto en execute([...]) y PDO emula las consultas preparadas, MySQL recibe LIMIT '5' y da un error de sintaxis.

Ordenamos por id DESC y no por fecha porque dos mensajes enviados en el mismo segundo tendrían la misma creado_en, y el orden entre ellos podría cambiar de una página a otra.

Paginación

Para mostrar la página n con 5 mensajes por página, hay que saltarse los (n - 1) * 5 primeros: ese número es el desplazamiento (OFFSET). También hay que saber cuántas páginas hay y corregir números imposibles (?pagina=99 o ?pagina=-3):

<?php
function calcularPaginacion(int $total, int $porPagina, int $pagina): array
{
    $paginas = max(1, (int) ceil($total / $porPagina));
    $pagina = min(max(1, $pagina), $paginas);
    return [
        'pagina'         => $pagina,
        'paginas'        => $paginas,
        'desplazamiento' => ($pagina - 1) * $porPagina,
    ];
}

print_r(calcularPaginacion(12, 5, 99));
// Array
// (
//     [pagina] => 3
//     [paginas] => 3
//     [desplazamiento] => 10
// )

Con 12 mensajes hay ceil(12 / 5) = 3 páginas, y la última muestra los mensajes 11 y 12. max(1, ...) evita que, con la tabla vacía, haya cero páginas.

Protección CSRF

CSRF (Cross-Site Request Forgery) es un ataque en el que otra web hace que el navegador de tu visitante envíe un formulario a tu aplicación sin que él lo sepa. La defensa es un token: un valor aleatorio que guardas en la sesión y metes como campo oculto en tu formulario. Una web ajena no puede leerlo, así que sus envíos llegan sin el token correcto y los rechazas.

<?php
function tokenCsrf(): string
{
    if (empty($_SESSION['csrf'])) {
        $_SESSION['csrf'] = bin2hex(random_bytes(32));
    }
    return $_SESSION['csrf'];
}

function csrfValido(mixed $token): bool
{
    return is_string($token)
        && isset($_SESSION['csrf'])
        && hash_equals($_SESSION['csrf'], $token);
}
  • random_bytes(32) genera 32 bytes aleatorios seguros; bin2hex los convierte en 64 caracteres hexadecimales.
  • hash_equals() compara en un tiempo constante, para no dar pistas a quien intente adivinar el token midiendo cuánto tarda la comparación.
  • is_string() evita un error si alguien envía csrf[]=x, que llega como array.

Presentación

Dos ayudas más para la vista: dar formato a la fecha que devuelve MySQL (2026-10-06 18:05:00) y pintar el error de un campo.

<?php
function formatearFecha(string $fechaBd): string
{
    $fecha = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $fechaBd);
    return $fecha ? $fecha->format('d/m/Y \a \l\a\s H:i') : $fechaBd;
}

function mostrarError(array $errores, string $campo): string
{
    if (!isset($errores[$campo])) {
        return '';
    }
    return '<p class="error" id="error-' . $campo . '">' . e($errores[$campo]) . '</p>';
}

echo formatearFecha('2026-10-06 18:05:00');
// 06/10/2026 a las 18:05

Las letras de “a las” van escapadas con \ porque en el formato de fechas a, l y s tienen significado propio (lo viste en Fechas).

Paso 5: la página completa

public/index.php hace tres cosas en orden: procesar el formulario si llega por POST, preparar los datos del listado y pintar el HTML. Todo el PHP “pesado” va arriba; abajo solo hay HTML con pequeñas inserciones.

<?php
declare(strict_types=1);

session_start();

require __DIR__ . '/../src/conexion.php';
require __DIR__ . '/../src/funciones.php';

$config = require __DIR__ . '/../src/config.php';

try {
    $pdo = conectar($config);
} catch (PDOException $ex) {
    error_log('Error de conexión: ' . $ex->getMessage());
    http_response_code(500);
    exit('El libro de visitas no está disponible ahora mismo. Inténtalo más tarde.');
}

// ---------- 1. Procesar el formulario ----------
$datos = ['nombre' => '', 'email' => '', 'mensaje' => ''];
$errores = [];

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    if (!csrfValido($_POST['csrf'] ?? null)) {
        http_response_code(400);
        exit('La sesión ha caducado o el formulario no es válido. Vuelve atrás y recarga la página.');
    }

    [$datos, $errores] = validarMensaje($_POST);

    if (!$errores) {
        guardarMensaje($pdo, $datos);
        $_SESSION['aviso'] = 'Gracias por tu mensaje, ' . $datos['nombre'] . '.';
        header('Location: index.php', true, 303);
        exit;
    }
}

// ---------- 2. Preparar el listado ----------
$aviso = $_SESSION['aviso'] ?? null;
unset($_SESSION['aviso']);

$paginaPedida = filter_var($_GET['pagina'] ?? 1, FILTER_VALIDATE_INT) ?: 1;
$total = contarMensajes($pdo);
$paginacion = calcularPaginacion($total, $config['por_pagina'], $paginaPedida);
$mensajes = obtenerMensajes($pdo, $config['por_pagina'], $paginacion['desplazamiento']);
?>
<!DOCTYPE html>
<html lang="es">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Libro de visitas · Refugio de Pombo</title>
  <link rel="stylesheet" href="estilos.css">
</head>
<body>
<main>
  <h1>Libro de visitas del Refugio de Pombo</h1>

  <?php if ($aviso): ?>
    <p class="aviso" role="status"><?= e($aviso) ?></p>
  <?php endif; ?>

  <section aria-labelledby="titulo-form">
    <h2 id="titulo-form">Deja tu mensaje</h2>

    <?php if ($errores): ?>
      <p class="error" role="alert">Revisa los campos marcados (<?= count($errores) ?>).</p>
    <?php endif; ?>

    <form method="post" action="index.php" novalidate>
      <input type="hidden" name="csrf" value="<?= e(tokenCsrf()) ?>">

      <label for="nombre">Nombre</label>
      <input id="nombre" name="nombre" required maxlength="60"
             value="<?= e($datos['nombre']) ?>"
             <?= isset($errores['nombre']) ? 'aria-invalid="true" aria-describedby="error-nombre"' : '' ?>>
      <?= mostrarError($errores, 'nombre') ?>

      <label for="email">Email (opcional, no se publica)</label>
      <input id="email" name="email" type="email" maxlength="120"
             value="<?= e($datos['email']) ?>"
             <?= isset($errores['email']) ? 'aria-invalid="true" aria-describedby="error-email"' : '' ?>>
      <?= mostrarError($errores, 'email') ?>

      <label for="mensaje">Mensaje</label>
      <textarea id="mensaje" name="mensaje" rows="5" required maxlength="1000"
                <?= isset($errores['mensaje']) ? 'aria-invalid="true" aria-describedby="error-mensaje"' : '' ?>><?= e($datos['mensaje']) ?></textarea>
      <?= mostrarError($errores, 'mensaje') ?>

      <button type="submit">Publicar</button>
    </form>
  </section>

  <section aria-labelledby="titulo-lista">
    <h2 id="titulo-lista">Mensajes (<?= $total ?>)</h2>

    <?php if (!$mensajes): ?>
      <p>Todavía no hay mensajes. ¡Sé el primero!</p>
    <?php endif; ?>

    <?php foreach ($mensajes as $m): ?>
      <article class="mensaje">
        <h3><?= e($m['nombre']) ?></h3>
        <p class="fecha"><time datetime="<?= e($m['creado_en']) ?>"><?= e(formatearFecha($m['creado_en'])) ?></time></p>
        <p><?= nl2br(e($m['mensaje'])) ?></p>
      </article>
    <?php endforeach; ?>

    <?php if ($paginacion['paginas'] > 1): ?>
      <nav class="paginacion" aria-label="Páginas de mensajes">
        <?php for ($i = 1; $i <= $paginacion['paginas']; $i++): ?>
          <?php if ($i === $paginacion['pagina']): ?>
            <span aria-current="page"><?= $i ?></span>
          <?php else: ?>
            <a href="?pagina=<?= $i ?>"><?= $i ?></a>
          <?php endif; ?>
        <?php endfor; ?>
      </nav>
    <?php endif; ?>
  </section>
</main>
</body>
</html>

Los detalles que marcan la diferencia:

  • El token CSRF se comprueba antes que nada. Si falla, no se valida ni se guarda.
  • PRG con código 303: tras guardar, el navegador hace un GET nuevo. Si el usuario pulsa F5, no reenvía el formulario.
  • Mensaje flash: el aviso se guarda en la sesión, se lee una vez y se borra con unset, así solo aparece justo después de publicar.
  • Escapar al imprimir, siempre: el nombre, el mensaje, la fecha, el valor de los campos y hasta el propio aviso, que incluye el nombre que escribió el usuario. Si alguien firma como <b>Íker</b>, se verá el texto literal, no una negrita.
  • nl2br(e(...)), en ese orden: primero escapas y después conviertes los saltos de línea en <br>. Al revés, e() escaparía también los <br>.
  • ?: 1 en la página: si ?pagina=abc, filter_var devuelve false y usamos la 1. Los valores fuera de rango los corrige calcularPaginacion().
  • Errores de conexión: el detalle técnico va al registro de errores con error_log(); el visitante solo ve un mensaje amable. Mostrar el mensaje de la excepción podría revelar el usuario o el servidor de la base de datos.

Paso 6: los estilos

public/estilos.css, breve y adaptable al móvil:

:root {
  --fondo: #f6f4ef;
  --texto: #222;
  --acento: #2f6b4f;
  --error: #b42318;
}

body {
  margin: 0;
  font-family: system-ui, sans-serif;
  background: var(--fondo);
  color: var(--texto);
  line-height: 1.5;
}

main { max-width: 40rem; margin-inline: auto; padding: 1.5rem 1rem; }
form { display: grid; gap: 0.4rem; margin-bottom: 2rem; }
input, textarea, button { font: inherit; padding: 0.5rem; }

input[aria-invalid="true"],
textarea[aria-invalid="true"] { border: 2px solid var(--error); }

button {
  justify-self: start;
  background: var(--acento);
  color: #fff;
  border: 0;
  border-radius: 0.4rem;
  cursor: pointer;
}

.error { color: var(--error); margin: 0; }
.aviso { background: #e3f1e8; padding: 0.75rem; border-radius: 0.4rem; }
.mensaje { background: #fff; padding: 1rem; border-radius: 0.5rem; margin-bottom: 1rem; }
.mensaje h3 { margin: 0; }
.fecha { margin: 0; font-size: 0.875rem; color: #666; }
.paginacion { display: flex; gap: 0.5rem; }
.paginacion a, .paginacion span { padding: 0.25rem 0.6rem; border-radius: 0.3rem; }
.paginacion span { background: var(--acento); color: #fff; }

Fíjate en que los campos con error se marcan usando el propio atributo aria-invalid: no hace falta una clase extra.

Paso 7: probarlo

Comprueba cada requisito de la especificación, no solo el caso feliz:

  1. Envía el formulario vacío: deben salir tres errores y el resumen “Revisa los campos marcados (2)” o “(3)”.
  2. Escribe un mensaje válido con un email incorrecto: el error aparece en el email y el texto del mensaje sigue en su sitio.
  3. Publica un mensaje correcto y pulsa F5: no debe duplicarse.
  4. Publica como nombre <script>alert(1)</script>: debe verse como texto.
  5. Publica 12 mensajes: deben aparecer los enlaces 1, 2 y 3. Prueba a mano ?pagina=99 y ?pagina=abc.
  6. Abre las DevTools, cambia el valor del campo oculto csrf y envía: debe responder “La sesión ha caducado…”.

Ideas para ampliarlo

  • Moderación: añade una columna aprobado TINYINT(1) DEFAULT 0 y muestra solo los aprobados. Crea una página admin.php protegida con login (password_hash/password_verify) para aprobarlos o borrarlos, como en CRUD con PDO.
  • Anti-spam sencillo: un campo trampa oculto con CSS que los humanos no rellenan y los robots sí; si llega con texto, descarta el envío. O limita a un mensaje por minuto guardando la hora del último en la sesión.
  • Enlaces Anterior/Siguiente además de los números, y ocultar páginas intermedias cuando haya muchas (1 … 7 8 9 … 40).
  • Valoración de 1 a 5 estrellas con <input type="radio">, validada con filter_var y rango.
  • Composer: mueve la configuración a un .env con phpdotenv y registra los errores con Monolog.
  • API: un api.php que devuelva los mensajes en JSON para cargarlos con fetch, como en JSON y APIs.

Errores frecuentes

  • Concatenar datos en el SQL ("... VALUES ('$nombre')"): abre la puerta a la inyección SQL. Usa siempre parámetros con prepare y execute.
  • Pasar LIMIT como texto: con consultas preparadas emuladas, MySQL recibe LIMIT '5' y falla. Usa bindValue(..., PDO::PARAM_INT).
  • Olvidar session_start(): el token CSRF no se guarda y todos los envíos se rechazan. Debe ir al principio, antes de cualquier salida HTML.
  • No redirigir tras guardar: al recargar, el navegador reenvía el POST y el mensaje se duplica.
  • e() después de nl2br(): los <br> aparecen como texto. Primero escapa, luego nl2br.
  • Guardar el texto ya escapado: luego se escapa dos veces y aparece &amp;lt;. Guarda el original y escapa al mostrar.
  • Poner src/ dentro de la raíz web: alguien podría pedir config.php por URL. Deja fuera de public/ todo lo que no deba descargarse.

Resumen

RequisitoCómo se resuelve
Organizaciónpublic/ como raíz web; src/ con configuración y funciones
Validacióntrim, mb_strlen, filter_var y un array de errores por campo
Guardadoprepare + execute con parámetros; NULL para el email vacío
Recarga sin duplicadosPRG: header('Location: ...', true, 303) + exit
Aviso de éxitoMensaje flash en $_SESSION, leído una vez y borrado
XSShtmlspecialchars (función e()) en todo lo que se imprime
Saltos de líneanl2br(e($texto))
PaginaciónCOUNT(*), ceil(), LIMIT y OFFSET enteros con bindValue
CSRFToken con random_bytes, campo oculto y hash_equals
Errores de conexiónerror_log() para ti, mensaje genérico para el visitante

Con este proyecto terminas el curso de PHP. Para sacar más partido a la base de datos, sigue con el curso de SQL: Introducción a SQL.

Pon a prueba lo que has aprendido

[PHP] ¿Qué defiende un token CSRF guardado en la sesión y enviado en un campo oculto?

[PHP] ¿Por qué se compara el token CSRF con hash_equals() y no con ===?

[PHP] Con consultas preparadas, ¿cómo pasas LIMIT y OFFSET a MySQL?

[PHP] Un mensaje contiene <b> y saltos de línea. ¿Cuál es el orden correcto para mostrarlo?

¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.