Saltar al contenido
crud.php · devschool

CRUD con PHP y PDO: crear, listar, editar y borrar

Lección 22 de 26 · 15 min de lectura · Actualizado el

En esta lección
  1. Qué vamos a construir
  2. La tabla
  3. Las funciones comunes
  4. Listar y buscar: index.php
  5. Crear y editar con el mismo formulario: formulario.php
  6. Borrar: borrar.php
  7. Paginar el listado
  8. Errores frecuentes
  9. Resumen

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:

ArchivoQué haceOperación
index.phpLista los libros, con buscador y botones de editar y borrarRead
formulario.phpFormulario para dar de alta un libro o editar uno existenteCreate y Update
borrar.phpRecibe la orden de borrar por POST y redirigeDelete
conexion.phpCrea $pdo (el de la lección anterior)
funciones.phpFunciones comunes: escapar, mensajes flash, validar y consultas

Cada operación encaja con un verbo SQL y con un método HTTP:

OperaciónSQLMétodo HTTP
Listar / verSELECTGET
CrearINSERTPOST
EditarUPDATEGET para ver el formulario, POST para guardar
BorrarDELETEPOST

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() devuelve null si no existe, gracias a ?:, que cambia el false de fetch() por null. El tipo ?array lo deja claro a quien use la función.
  • [...$libro, 'id' => $id] crea un array nuevo con todo lo de $libro más la clave id. 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:

  1. 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.
  2. Envío (POST a la misma URL, que conserva el ?id=2): se valida lo recibido. Si hay errores, $libro contiene lo que escribió el usuario y el formulario se repinta con los mensajes.
  3. Todo correcto: se hace INSERT o UPDATE, 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, min y max el navegador avisa antes de enviar, lo que es cómodo para el usuario. Pero la validación que cuenta es la de validarLibro(), 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 NULL y 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 id inventado el formulario aparece vacío y al guardar no actualiza nada. Responde con 404.
  • Coger el id de 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ónPáginaClave del código
Listarindex.php (GET)SELECT ... ORDER BY y foreach con e()
Buscarindex.php?q=... (GET)LIKE ? con '%' . $q . '%'
Crearformulario.php (POST)Validar, INSERT, flash y redirección 303
Editarformulario.php?id=N (GET y POST)Cargar con fetch(), 404 si no existe, UPDATE ... WHERE id = :id
Borrarborrar.php (solo POST)DELETE ... WHERE id = ? y rowCount()
Paginarindex.php?pagina=NLIMIT y OFFSET con PDO::PARAM_INT
Avisar tras redirigirTodasMensajes 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

[PHP] ¿Por qué borrar con un enlace GET (borrar.php?id=5) es mala idea?

[PHP] Tras guardar un libro nuevo con éxito, ¿qué debe hacer el script?

[PHP] ¿Qué muestra este código?
function borrarLibro(PDO $pdo, int $id): bool
{
    $c = $pdo->prepare('DELETE FROM libros WHERE id = ?');
    $c->execute([$id]);
    return $c->rowCount() === 1;
}

var_dump(borrarLibro($pdo, 3));   // existía
var_dump(borrarLibro($pdo, 3));

[PHP] En formulario.php?id=999 no existe ese libro. ¿Qué es lo correcto?

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