Mini CRUD con PHP + PDO

Ejemplo básico y seguro de INSERT, LISTAR y DELETE con PDO, prepared statements, PRG y token CSRF para borrar.

Índice

1) Tabla de ejemplo (MySQL/MariaDB)

-- Tabla 'usuarios'
CREATE TABLE usuarios (
  id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  nombre VARCHAR(80) NOT NULL,
  email VARCHAR(120) NOT NULL UNIQUE,
  creado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

2) Estructura de archivos

/crud
  config/
    db.php
  public/
    form.html
    insert.php
    list.php
    delete.php

3) Conexión reusable (config/db.php)

<?php
// config/db.php
declare(strict_types=1);

function pdo(): PDO {
  $dsn  = 'mysql:host=localhost;dbname=miapp;charset=utf8mb4';
  $user = 'usuario_app';
  $pass = 'secreto';
  return new PDO($dsn, $user, $pass, [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    PDO::ATTR_EMULATE_PREPARES   => false,
  ]);
}
?>

4) Formulario de alta (public/form.html)

<!DOCTYPE html>
<html lang="es">
<head><meta charset="UTF-8"><title>Alta de usuario</title></head>
<body>
  <nav>
    <a href="form.html">Alta</a> | 
    <a href="list.php">Listado</a>
  </nav>

  <h1>Alta de usuario</h1>
  <form action="insert.php" method="post" autocomplete="off">
    <label>Nombre</label>
    <input type="text" name="nombre" required maxlength="80">

    <label>Email</label>
    <input type="email" name="email" required maxlength="120">

    <button type="submit">Guardar</button>
  </form>

  <?php
  // Mensaje flash por PRG
  session_start();
  if (!empty($_SESSION['flash'])) {
    echo '<p>' . htmlspecialchars($_SESSION['flash'], ENT_QUOTES, 'UTF-8') . '</p>';
    unset($_SESSION['flash']);
  }
  ?>
</body>
</html>

5) Procesar alta (public/insert.php)

<?php
declare(strict_types=1);
session_start();

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
  http_response_code(405);
  exit('Método no permitido');
}

$nombre = trim($_POST['nombre'] ?? '');
$email  = trim($_POST['email'] ?? '');

if ($nombre === '' || $email === '') {
  $_SESSION['flash'] = 'Completá todos los campos.';
  header('Location: form.html'); exit;
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
  $_SESSION['flash'] = 'Email inválido.';
  header('Location: form.html'); exit;
}

require __DIR__ . '/../config/db.php';

try {
  $pdo = pdo();
  $stmt = $pdo->prepare('INSERT INTO usuarios (nombre, email) VALUES (:n, :e)');
  $stmt->execute([':n' => $nombre, ':e' => $email]);

  $_SESSION['flash'] = 'Usuario creado (ID: ' . (int)$pdo->lastInsertId() . ') ✅';
} catch (PDOException $ex) {
  if ((int)($ex->errorInfo[1] ?? 0) === 1062) {
    $_SESSION['flash'] = 'El email ya existe.';
  } else {
    // error_log($ex->getMessage());
    $_SESSION['flash'] = 'Ocurrió un error. Intente nuevamente.';
  }
}
header('Location: form.html'); exit;
?>

6) Listado (public/list.php)

Lista todos los usuarios y ofrece un botón de borrado por POST con CSRF token.

<?php
declare(strict_types=1);
session_start();

require __DIR__ . '/../config/db.php';
$pdo = pdo();

// Generar token CSRF si no existe
if (empty($_SESSION['csrf'])) {
  $_SESSION['csrf'] = bin2hex(random_bytes(16));
}
$csrf = $_SESSION['csrf'];

// Paginación simple (opcional)
$page = max(1, (int)($_GET['page'] ?? 1));
$perPage = 10;
$offset = ($page - 1) * $perPage;

// Total y fetch
$total = (int)$pdo->query('SELECT COUNT(*) FROM usuarios')->fetchColumn();
$stmt  = $pdo->prepare('SELECT id, nombre, email, creado_en FROM usuarios ORDER BY id DESC LIMIT :lim OFFSET :off');
$stmt->bindValue(':lim', $perPage, PDO::PARAM_INT);
$stmt->bindValue(':off', $offset, PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll();
?>

<!DOCTYPE html>
<html lang="es">
<head><meta charset="UTF-8"><title>Listado de usuarios</title></head>
<body>
  <nav>
    <a href="form.html">Alta</a> | 
    <a href="list.php">Listado</a>
  </nav>

  <h1>Usuarios (ID</th><th>Nombre</th><th>Email</th><th>Creado</th><th>Acciones</th></tr>
    <?php foreach ($rows as $r): ?>
      <tr>
        <td><?= (int)$r['id'] ?></td>
        <td><?= htmlspecialchars($r['nombre'], ENT_QUOTES, 'UTF-8') ?></td>
        <td><?= htmlspecialchars($r['email'], ENT_QUOTES, 'UTF-8') ?></td>
        <td><?= htmlspecialchars($r['creado_en'], ENT_QUOTES, 'UTF-8') ?></td>
        <td>
          <form action="delete.php" method="post" onsubmit="return confirm('¿Borrar usuario?');" style="display:inline">
            <input type="hidden" name="id" value="<?= (int)$r['id'] ?>">
            <input type="hidden" name="csrf" value="<?= $csrf ?>">
            <button type="submit">Borrar</button>
          </form>
        </td>
      </tr>
    <?php endforeach; ?>
  </table>

  <!-- Paginación mínima -->
  <div>
    <?php
      $pages = max(1, (int)ceil($total / $perPage));
      for ($i=1; $i<=$pages; $i++) {
        echo ($i === $page) ? "<strong>$i</strong> " : '<a href="?page='.$i.'">'.$i.'</a> ';
      }
    ?>
  </div>
</body>
</html>

7) Borrado seguro (public/delete.php)

Valida POST, token CSRF y el ID antes de borrar. Aplica PRG.

<?php
declare(strict_types=1);
session_start();

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
  http_response_code(405);
  exit('Método no permitido');
}

$id   = (int)($_POST['id'] ?? 0);
$csrf = $_POST['csrf'] ?? '';

if ($id <= 0 || empty($_SESSION['csrf']) || !hash_equals($_SESSION['csrf'], $csrf)) {
  $_SESSION['flash'] = 'Solicitud inválida.';
  header('Location: list.php'); exit;
}

require __DIR__ . '/../config/db.php';

try {
  $pdo = pdo();
  $stmt = $pdo->prepare('DELETE FROM usuarios WHERE id = :id');
  $stmt->execute([':id' => $id]);

  $_SESSION['flash'] = $stmt->rowCount() ? 'Usuario borrado ✅' : 'No se encontró el usuario.';
} catch (Throwable $ex) {
  // error_log($ex->getMessage());
  $_SESSION['flash'] = 'No se pudo borrar. Intente nuevamente.';
}

header('Location: list.php'); exit;
?>

8) Buenas prácticas y notas

Extensión: Para un CRUD completo, sumá EDITAR/UPDATE con un formulario que precargue datos y procese un UPDATE ... WHERE id = :id por POST con CSRF.