Proyecto PHP: libro de visitas con MySQL y PDO
En esta lección
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:
- Un formulario con nombre (obligatorio), email (opcional, no se publica) y mensaje (obligatorio).
- Validación en el servidor, con los errores junto a cada campo y sin perder lo que había escrito el usuario.
- Los mensajes válidos se guardan en una tabla de MySQL usando PDO con consultas preparadas.
- 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.
- Debajo del formulario, la lista de mensajes del más nuevo al más antiguo, escapados con
htmlspecialcharspara evitar XSS. - Paginación: cinco mensajes por página, con enlaces 1, 2, 3…
- 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:
utf8mb4admite cualquier carácter, incluidas tildes, eñes y emojis. El antiguoutf8de MySQL no los admite todos.emailadmiteNULLporque es opcional: guardaremosNULLen vez de una cadena vacía, que es la forma correcta de decir “no hay dato”.creado_ense rellena sola conDEFAULT CURRENT_TIMESTAMP, así PHP no tiene que enviar la fecha.- Las longitudes de
VARCHARcoinciden 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;bin2hexlos 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íacsrf[]=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>.?: 1en la página: si?pagina=abc,filter_vardevuelvefalsey usamos la 1. Los valores fuera de rango los corrigecalcularPaginacion().- 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:
- Envía el formulario vacío: deben salir tres errores y el resumen “Revisa los campos marcados (2)” o “(3)”.
- Escribe un mensaje válido con un email incorrecto: el error aparece en el email y el texto del mensaje sigue en su sitio.
- Publica un mensaje correcto y pulsa F5: no debe duplicarse.
- Publica como nombre
<script>alert(1)</script>: debe verse como texto. - Publica 12 mensajes: deben aparecer los enlaces 1, 2 y 3. Prueba a mano
?pagina=99y?pagina=abc. - Abre las DevTools, cambia el valor del campo oculto
csrfy envía: debe responder “La sesión ha caducado…”.
Ideas para ampliarlo
- Moderación: añade una columna
aprobado TINYINT(1) DEFAULT 0y muestra solo los aprobados. Crea una páginaadmin.phpprotegida 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 confilter_vary rango. - Composer: mueve la configuración a un
.envcon phpdotenv y registra los errores con Monolog. - API: un
api.phpque devuelva los mensajes en JSON para cargarlos confetch, 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 conprepareyexecute. - Pasar
LIMITcomo texto: con consultas preparadas emuladas, MySQL recibeLIMIT '5'y falla. UsabindValue(..., 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 denl2br(): los<br>aparecen como texto. Primero escapa, luegonl2br.- Guardar el texto ya escapado: luego se escapa dos veces y aparece
&lt;. Guarda el original y escapa al mostrar. - Poner
src/dentro de la raíz web: alguien podría pedirconfig.phppor URL. Deja fuera depublic/todo lo que no deba descargarse.
Resumen
| Requisito | Cómo se resuelve |
|---|---|
| Organización | public/ como raíz web; src/ con configuración y funciones |
| Validación | trim, mb_strlen, filter_var y un array de errores por campo |
| Guardado | prepare + execute con parámetros; NULL para el email vacío |
| Recarga sin duplicados | PRG: header('Location: ...', true, 303) + exit |
| Aviso de éxito | Mensaje flash en $_SESSION, leído una vez y borrado |
| XSS | htmlspecialchars (función e()) en todo lo que se imprime |
| Saltos de línea | nl2br(e($texto)) |
| Paginación | COUNT(*), ceil(), LIMIT y OFFSET enteros con bindValue |
| CSRF | Token con random_bytes, campo oculto y hash_equals |
| Errores de conexión | error_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
¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.