CRUD con PHP y PDO: crear, listar, editar y borrar
En esta lección
Casi todas las aplicaciones web hacen lo mismo con sus datos: crearlos, leerlos, actualizarlos y borrarlos. Esas cuatro operaciones se conocen como CRUD (Create, Read, Update, Delete), y un panel de administración, una tienda o una agenda son, en el fondo, varios CRUD juntos. En esta lección construirás uno completo para el catálogo de una biblioteca de barrio, uniendo lo que ya sabes de formularios, validación, sesiones y PDO.
Qué vamos a construir
Una pequeña aplicación con tres páginas y dos archivos de apoyo:
| Archivo | Qué hace | Operación |
|---|---|---|
index.php | Lista los libros, con buscador y botones de editar y borrar | Read |
formulario.php | Formulario para dar de alta un libro o editar uno existente | Create y Update |
borrar.php | Recibe la orden de borrar por POST y redirige | Delete |
conexion.php | Crea $pdo (el de la lección anterior) | |
funciones.php | Funciones comunes: escapar, mensajes flash, validar y consultas |
Cada operación encaja con un verbo SQL y con un método HTTP:
| Operación | SQL | Método HTTP |
|---|---|---|
| Listar / ver | SELECT | GET |
| Crear | INSERT | POST |
| Editar | UPDATE | GET para ver el formulario, POST para guardar |
| Borrar | DELETE | POST |
La regla que viste en formularios se cumple aquí al pie de la letra: GET para pedir, POST para cambiar.
La tabla
CREATE TABLE libros (
id INT AUTO_INCREMENT PRIMARY KEY,
titulo VARCHAR(150) NOT NULL,
autor VARCHAR(100) NOT NULL,
anio SMALLINT NOT NULL,
genero VARCHAR(20) NOT NULL
);
INSERT INTO libros (titulo, autor, anio, genero) VALUES
('Nada', 'Carmen Laforet', 1945, 'novela'),
('Patria', 'Fernando Aramburu', 2016, 'novela'),
('Mafalda 1', 'Quino', 1966, 'comic');
La columna se llama anio y no año para evitar problemas con caracteres especiales en nombres de columnas.
Las funciones comunes
Separar las consultas del HTML hace que cada página sea corta y fácil de leer, y te permite probar las funciones por separado. Empezamos por funciones.php.
Escapar y mensajes flash
Dos ayudas que ya conoces: e() para escapar al imprimir y los mensajes flash para avisar después de redirigir.
<?php
// funciones.php (1/3)
function e(string|int|null $texto): string
{
return htmlspecialchars((string) $texto, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function flash(string $mensaje): void
{
$_SESSION['flash'] = $mensaje;
}
function leerFlash(): ?string
{
$mensaje = $_SESSION['flash'] ?? null;
unset($_SESSION['flash']);
return $mensaje;
}
Validar un libro
La validación devuelve dos cosas: el libro limpio (para guardarlo o para repintar el formulario) y el array de errores. Una función solo puede devolver un valor, así que devolvemos un array de dos elementos y lo separamos al recibirlo con [$libro, $errores] = ..., la desestructuración de arrays.
<?php
// funciones.php (2/3)
const GENEROS = ['novela', 'ensayo', 'poesia', 'comic', 'infantil'];
function validarLibro(array $entrada): array
{
$libro = [
'titulo' => trim($entrada['titulo'] ?? ''),
'autor' => trim($entrada['autor'] ?? ''),
'anio' => trim($entrada['anio'] ?? ''),
'genero' => $entrada['genero'] ?? '',
];
$errores = [];
if ($libro['titulo'] === '' || mb_strlen($libro['titulo']) > 150) {
$errores['titulo'] = 'El título es obligatorio (máximo 150 caracteres).';
}
if ($libro['autor'] === '' || mb_strlen($libro['autor']) > 100) {
$errores['autor'] = 'El autor es obligatorio (máximo 100 caracteres).';
}
$anio = filter_var($libro['anio'], FILTER_VALIDATE_INT,
['options' => ['min_range' => 1450, 'max_range' => (int) date('Y')]]);
if ($anio === false) {
$errores['anio'] = 'Escribe un año entre 1450 y ' . date('Y') . '.';
}
if (!in_array($libro['genero'], GENEROS, true)) {
$errores['genero'] = 'Elige un género de la lista.';
}
return [$libro, $errores];
}
Las claves de $libro coinciden con los marcadores de las consultas (:titulo, :autor…). Así podrás pasar el array directamente a execute().
Las comprobaciones de longitud no son un capricho: coinciden con los VARCHAR(150) y VARCHAR(100) de la tabla. Si dejas pasar un título más largo, MySQL dará error o lo cortará, según su configuración.
Las consultas
Una función por operación, todas con consultas preparadas:
<?php
// funciones.php (3/3)
function listarLibros(PDO $pdo, string $busqueda = ''): array
{
if ($busqueda === '') {
return $pdo->query('SELECT * FROM libros ORDER BY titulo')->fetchAll();
}
$patron = '%' . $busqueda . '%';
$consulta = $pdo->prepare('SELECT * FROM libros WHERE titulo LIKE ? OR autor LIKE ? ORDER BY titulo');
$consulta->execute([$patron, $patron]);
return $consulta->fetchAll();
}
function obtenerLibro(PDO $pdo, int $id): ?array
{
$consulta = $pdo->prepare('SELECT * FROM libros WHERE id = ?');
$consulta->execute([$id]);
return $consulta->fetch() ?: null;
}
function crearLibro(PDO $pdo, array $libro): int
{
$consulta = $pdo->prepare(
'INSERT INTO libros (titulo, autor, anio, genero) VALUES (:titulo, :autor, :anio, :genero)'
);
$consulta->execute($libro);
return (int) $pdo->lastInsertId();
}
function actualizarLibro(PDO $pdo, int $id, array $libro): void
{
$consulta = $pdo->prepare(
'UPDATE libros SET titulo = :titulo, autor = :autor, anio = :anio, genero = :genero WHERE id = :id'
);
$consulta->execute([...$libro, 'id' => $id]);
}
function borrarLibro(PDO $pdo, int $id): bool
{
$consulta = $pdo->prepare('DELETE FROM libros WHERE id = ?');
$consulta->execute([$id]);
return $consulta->rowCount() === 1;
}
Algunos detalles que merece la pena mirar:
- En la búsqueda, el mismo patrón se usa dos veces. Con
?lo pasas dos veces en el array. Con marcadores con nombre no podrías repetir:texto: con las consultas preparadas reales de MySQL, cada nombre solo puede aparecer una vez. obtenerLibro()devuelvenullsi no existe, gracias a?:, que cambia elfalsedefetch()pornull. El tipo?arraylo deja claro a quien use la función.[...$libro, 'id' => $id]crea un array nuevo con todo lo de$libromás la claveid. Es el operador de propagación (spread), que admite claves de texto desde PHP 8.1.borrarLibro()devuelve si de verdad se borró algo, para poder avisar si el libro ya no existía.
Probar las funciones sin servidor
Como las funciones reciben el $pdo como parámetro, puedes probarlas desde la terminal con SQLite en memoria antes de escribir una sola línea de HTML:
<?php
require __DIR__ . '/funciones.php';
$pdo = new PDO('sqlite::memory:', null, null, [PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);
$pdo->exec('CREATE TABLE libros (id INTEGER PRIMARY KEY AUTOINCREMENT, titulo TEXT, autor TEXT, anio INTEGER, genero TEXT)');
$pdo->exec("INSERT INTO libros (titulo, autor, anio, genero) VALUES
('Nada', 'Carmen Laforet', 1945, 'novela'),
('Patria', 'Fernando Aramburu', 2016, 'novela'),
('Mafalda 1', 'Quino', 1966, 'comic')");
[$libro, $errores] = validarLibro(['titulo' => ' El infinito en un junco ', 'autor' => 'Irene Vallejo',
'anio' => '2019', 'genero' => 'ensayo']);
echo count($errores), " errores\n"; // 0 errores
echo 'Creado con id ', crearLibro($pdo, $libro), "\n"; // Creado con id 4
[, $errores] = validarLibro(['titulo' => '', 'autor' => 'Anónimo', 'anio' => '3000', 'genero' => 'terror']);
echo implode(', ', array_keys($errores)), "\n"; // titulo, anio, genero
echo listarLibros($pdo, 'aramburu')[0]['titulo'], "\n"; // Patria
var_dump(obtenerLibro($pdo, 99)); // NULL
var_dump(borrarLibro($pdo, 3)); // bool(true)
var_dump(borrarLibro($pdo, 3)); // bool(false)
echo implode(' | ', array_column(listarLibros($pdo), 'titulo'));
// El infinito en un junco | Nada | Patria
array_column() saca una columna de un array de filas: muy cómodo con los resultados de fetchAll().
Listar y buscar: index.php
La página principal arranca la sesión, carga los libros (filtrados si hay búsqueda) y los pinta en una tabla. Todo lo que sale de la base de datos se escapa con e(), porque lo escribió un usuario en algún momento.
<?php
// index.php
session_start();
require __DIR__ . '/conexion.php';
require __DIR__ . '/funciones.php';
$busqueda = trim($_GET['q'] ?? '');
$libros = listarLibros($pdo, $busqueda);
$mensaje = leerFlash();
?>
<!DOCTYPE html>
<html lang="es">
<head><meta charset="utf-8"><title>Catálogo de la biblioteca</title></head>
<body>
<h1>Catálogo</h1>
<?php if ($mensaje): ?>
<p class="aviso"><?= e($mensaje) ?></p>
<?php endif; ?>
<form method="get">
<label for="q">Buscar por título o autor</label>
<input type="search" id="q" name="q" value="<?= e($busqueda) ?>">
<button>Buscar</button>
</form>
<p><a href="formulario.php">Añadir un libro</a></p>
<?php if (!$libros): ?>
<p>No hay libros que mostrar.</p>
<?php else: ?>
<table>
<thead>
<tr><th>Título</th><th>Autor</th><th>Año</th><th>Género</th><th>Acciones</th></tr>
</thead>
<tbody>
<?php foreach ($libros as $libro): ?>
<tr>
<td><?= e($libro['titulo']) ?></td>
<td><?= e($libro['autor']) ?></td>
<td><?= e($libro['anio']) ?></td>
<td><?= e($libro['genero']) ?></td>
<td>
<a href="formulario.php?id=<?= $libro['id'] ?>">Editar</a>
<form method="post" action="borrar.php" class="en-linea">
<input type="hidden" name="id" value="<?= $libro['id'] ?>">
<button>Borrar</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</body>
</html>
La búsqueda va por GET: no cambia nada y así el usuario puede guardar o compartir la URL index.php?q=laforet. Fíjate en que el valor buscado se repinta en el campo, escapado.
El id se imprime sin e() porque es un entero que genera la base de datos, no texto de un usuario. Si tienes dudas, escápalo: no hace daño.
Crear y editar con el mismo formulario: formulario.php
Crear y editar se parecen mucho: el mismo formulario, las mismas validaciones. La única diferencia es que al editar el formulario empieza relleno y al guardar se hace UPDATE en lugar de INSERT. Por eso usamos una sola página que decide qué hacer según si llega un id en la URL:
formulario.php→ alta de un libro nuevo.formulario.php?id=2→ edición del libro 2.
<?php
// formulario.php
session_start();
require __DIR__ . '/conexion.php';
require __DIR__ . '/funciones.php';
// ¿Editar o crear?
$id = null;
if (isset($_GET['id'])) {
$id = filter_var($_GET['id'], FILTER_VALIDATE_INT);
$existente = $id ? obtenerLibro($pdo, $id) : null;
if ($existente === null) {
http_response_code(404);
exit('Ese libro no existe.');
}
}
$libro = $existente ?? ['titulo' => '', 'autor' => '', 'anio' => '', 'genero' => ''];
$errores = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
[$libro, $errores] = validarLibro($_POST);
if (!$errores) {
if ($id) {
actualizarLibro($pdo, $id, $libro);
flash('Libro actualizado.');
} else {
crearLibro($pdo, $libro);
flash('Libro añadido al catálogo.');
}
header('Location: index.php', true, 303);
exit;
}
}
?>
<!DOCTYPE html>
<html lang="es">
<head><meta charset="utf-8"><title><?= $id ? 'Editar libro' : 'Nuevo libro' ?></title></head>
<body>
<h1><?= $id ? 'Editar libro' : 'Nuevo libro' ?></h1>
<form method="post">
<p>
<label for="titulo">Título</label>
<input id="titulo" name="titulo" value="<?= e($libro['titulo']) ?>" maxlength="150" required>
<?php if (isset($errores['titulo'])): ?><span class="error"><?= e($errores['titulo']) ?></span><?php endif; ?>
</p>
<p>
<label for="autor">Autor</label>
<input id="autor" name="autor" value="<?= e($libro['autor']) ?>" maxlength="100" required>
<?php if (isset($errores['autor'])): ?><span class="error"><?= e($errores['autor']) ?></span><?php endif; ?>
</p>
<p>
<label for="anio">Año</label>
<input type="number" id="anio" name="anio" value="<?= e($libro['anio']) ?>" min="1450" max="<?= date('Y') ?>" required>
<?php if (isset($errores['anio'])): ?><span class="error"><?= e($errores['anio']) ?></span><?php endif; ?>
</p>
<p>
<label for="genero">Género</label>
<select id="genero" name="genero">
<option value="">Elige uno</option>
<?php foreach (GENEROS as $g): ?>
<option value="<?= $g ?>" <?= $libro['genero'] === $g ? 'selected' : '' ?>><?= ucfirst($g) ?></option>
<?php endforeach; ?>
</select>
<?php if (isset($errores['genero'])): ?><span class="error"><?= e($errores['genero']) ?></span><?php endif; ?>
</p>
<button>Guardar</button>
<a href="index.php">Cancelar</a>
</form>
</body>
</html>
Repasa el flujo, porque es el corazón del CRUD:
- Primera visita (GET): si hay
id, se carga el libro de la base de datos; si no, se usa un libro vacío. Se pinta el formulario. - Envío (POST a la misma URL, que conserva el
?id=2): se valida lo recibido. Si hay errores,$librocontiene lo que escribió el usuario y el formulario se repinta con los mensajes. - Todo correcto: se hace
INSERToUPDATE, se guarda un mensaje flash y se redirige al listado con el patrón PRG. Si el usuario recarga, no se duplica nada.
El id que se edita sale de la URL, no de un campo oculto del formulario. Así no hay dos fuentes que puedan contradecirse. Y si alguien escribe ?id=abc o el id de un libro borrado, recibe un 404 (no encontrado) en vez de un formulario vacío que acabaría creando un libro nuevo.
Nota: con
maxlength,required,minymaxel navegador avisa antes de enviar, lo que es cómodo para el usuario. Pero la validación que cuenta es la devalidarLibro(), como viste en Validar y sanear.
Borrar: borrar.php
Borrar es la operación más delicada, y por eso se hace siempre por POST, nunca con un enlace como borrar.php?id=3. Un enlace lo puede seguir cualquiera sin querer: un buscador que rastrea tu web, una extensión del navegador que precarga enlaces, o alguien que te manda esa URL camuflada. Con un formulario POST, además, puedes proteger la acción con un token CSRF, que verás en Seguridad.
<?php
// borrar.php
session_start();
require __DIR__ . '/conexion.php';
require __DIR__ . '/funciones.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405); // método no permitido
exit('Solo se puede borrar desde el formulario.');
}
$id = filter_var($_POST['id'] ?? '', FILTER_VALIDATE_INT);
if ($id && borrarLibro($pdo, $id)) {
flash('Libro borrado.');
} else {
flash('Ese libro ya no existía.');
}
header('Location: index.php', true, 303);
exit;
Esta página no tiene HTML: hace su trabajo y redirige. El mensaje flash aparecerá en el listado.
Pedir confirmación
Un clic por error en “Borrar” no tiene vuelta atrás. La forma más sencilla de pedir confirmación es un pequeño script en el navegador que pregunte antes de enviar el formulario:
<script>
document.querySelectorAll('form[action="borrar.php"]').forEach((form) => {
form.addEventListener('submit', (evento) => {
if (!confirm('¿Seguro que quieres borrar este libro?')) {
evento.preventDefault();
}
});
});
</script>
Si quieres algo más elegante que confirm(), puedes usar un <dialog> como en details y dialog. Otra opción, sin JavaScript, es que el botón lleve a una página de confirmación que muestre el título del libro y tenga el formulario POST definitivo.
Consejo: en aplicaciones reales muchas veces no se borra de verdad: se añade una columna
borrado_en DATETIME NULLy se marca la fecha. Es el borrado lógico: el registro desaparece de los listados (WHERE borrado_en IS NULL), pero se puede recuperar.
Paginar el listado
Con miles de libros no puedes mostrarlos todos de golpe. La paginación usa LIMIT y OFFSET como viste en la lección de PDO, más un COUNT(*) para saber cuántas páginas hay:
<?php
function paginarLibros(PDO $pdo, int $pagina, int $porPagina = 20): array
{
$total = (int) $pdo->query('SELECT COUNT(*) FROM libros')->fetchColumn();
$paginas = max(1, (int) ceil($total / $porPagina));
$pagina = min(max(1, $pagina), $paginas); // entre 1 y la última
$consulta = $pdo->prepare('SELECT * FROM libros ORDER BY titulo LIMIT :limite OFFSET :desde');
$consulta->bindValue('limite', $porPagina, PDO::PARAM_INT);
$consulta->bindValue('desde', ($pagina - 1) * $porPagina, PDO::PARAM_INT);
$consulta->execute();
return ['libros' => $consulta->fetchAll(), 'pagina' => $pagina, 'paginas' => $paginas];
}
$datos = paginarLibros($pdo, 2, 2);
echo "Página {$datos['pagina']} de {$datos['paginas']}: ", $datos['libros'][0]['titulo'];
// Página 2 de 2: Patria
En index.php leerías la página de la URL con (int) ($_GET['pagina'] ?? 1) y pintarías enlaces “Anterior” y “Siguiente” hacia index.php?pagina=.... El min(max(...)) evita páginas negativas o mayores que la última, aunque alguien escriba ?pagina=9999.
Errores frecuentes
- Borrar con un enlace GET: cualquiera (o cualquier robot) que siga el enlace borra datos. Usa un formulario POST.
- No redirigir tras guardar: al recargar, el navegador reenvía el POST y se crea un duplicado. Aplica PRG:
header('Location: ...', true, 303); exit;. - Olvidar escapar lo que sale de la base de datos: un título con
<script>guardado por alguien se ejecutaría en el listado.e()en todo lo que imprimas. - No comprobar que el registro existe al editar: con un
idinventado el formulario aparece vacío y al guardar no actualiza nada. Responde con 404. - Coger el
idde dos sitios (URL y campo oculto): pueden no coincidir. Decide una única fuente. - Repetir el mismo marcador con nombre en una consulta: con las consultas preparadas reales de MySQL da error. Usa nombres distintos o
?. - Perder lo escrito cuando hay errores: repinta cada campo con el valor recibido, escapado.
Resumen
| Operación | Página | Clave del código |
|---|---|---|
| Listar | index.php (GET) | SELECT ... ORDER BY y foreach con e() |
| Buscar | index.php?q=... (GET) | LIKE ? con '%' . $q . '%' |
| Crear | formulario.php (POST) | Validar, INSERT, flash y redirección 303 |
| Editar | formulario.php?id=N (GET y POST) | Cargar con fetch(), 404 si no existe, UPDATE ... WHERE id = :id |
| Borrar | borrar.php (solo POST) | DELETE ... WHERE id = ? y rowCount() |
| Paginar | index.php?pagina=N | LIMIT y OFFSET con PDO::PARAM_INT |
| Avisar tras redirigir | Todas | Mensajes flash en $_SESSION |
Tu CRUD funciona, pero todavía le falta una defensa importante contra formularios enviados desde otras webs. En la siguiente lección repasarás los ataques más comunes y cómo protegerte: Seguridad en PHP.
Pon a prueba lo que has aprendido
¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.