⚙️ 2.5 · CRUD con PHP y MySQL: tu primera app completa

⏱ 6h 00min ⚡ 100 XP 🏅 Practicante 📖 Backend FullStack
"El 80% de las aplicaciones web son CRUD. Domínalo y podrás construir casi cualquier cosa."

🎯 Objetivo del tema

Al terminar este tema serás capaz de: Al terminar este tema serás capaz de implementar un CRUD completo (Create, Read, Update, Delete) con PHP, MySQL y PDO. Tu aplicación podrá listar registros, mostrar detalles, crear nuevos, editar existentes y eliminar, todo con prepared statements para evitar SQL injection. El resultado: una app funcional lista para desplegar en tu hosting.
🎬
Video del instructor

El administrador aún no ha insertado un video para esta sección.

🗺️ Mapa del tema

CRUD es el pan de cada día del desarrollo backend. El 80% de las aplicaciones web son variaciones de CRUD: un blog (CRUD de posts), una tienda (CRUD de productos), una red social (CRUD de usuarios/posts/comentarios), un sistema de tickets (CRUD de tickets). Dominar CRUD es dominar la base de la programación backend. Vamos a hacerlo bien desde el inicio.

1. Qué es CRUD y dónde se usa

El acrónimo más repetido del desarrollo backend.

2. READ: listar y mostrar registros

SELECT con WHERE, ORDER BY, LIMIT, y los 3 patrones de listado.

3. CREATE: insertar nuevos registros

INSERT con PDO y validación en el servidor.

4. UPDATE: editar registros existentes

UPDATE con formulario pre-rellenado y patrón POST-Redirect-GET.

5. DELETE: eliminar registros con confirmación

DELETE con confirmación JavaScript y soft delete.

6. Paginación: el patrón que escala

LIMIT y OFFSET: cómo mostrar 1 millón de registros sin romper la página.

2.5.1 Qué es CRUD y dónde se usa

CRUD son las 4 operaciones básicas que cualquier aplicación que persiste datos necesita: Create (crear), Read (leer), Update (actualizar), Delete (eliminar). Se mapean directamente a los 4 verbos SQL (INSERT, SELECT, UPDATE, DELETE) y a los 4 verbos HTTP (POST, GET, PUT/PATCH, DELETE).

📷 Imagen referencial: El ciclo CRUD: Create (INSERT) -> Read (SELECT) -> Update (UPDATE) -> Delete (DELETE), conectado en un ciclo continuo.
OperaciónSQLHTTPURL típicaEjemplo
Create (Crear)INSERTPOST/usuarios/crearRegistrar nuevo usuario.
Read (Leer)SELECTGET/usuariosListar todos los usuarios.
Read (uno)SELECT WHEREGET/usuarios/ver/5Ver detalles de un usuario.
Update (Actualizar)UPDATEPOST o PUT/usuarios/editar/5Modificar usuario.
Delete (Eliminar)DELETEPOST o DELETE/usuarios/eliminar/5Borrar usuario.
⭐ CRUD = el 80%: El 80% de las aplicaciones web son CRUD. Un blog: CRUD de posts. Una tienda: CRUD de productos. Una red social: CRUD de usuarios, posts, comentarios. Un sistema de tickets: CRUD de tickets. Si dominas CRUD, puedes construir el 80% de las apps que te pidan. El 20% restante son cosas como reportes, dashboards, integraciones, etc.
🎬
Video del instructor

El administrador aún no ha insertado un video para esta sección.

2.5.2 READ: listar y mostrar registros

La operación más común: mostrar datos. En PHP, esto se hace con un SELECT a la base de datos, recorrer los resultados con foreach, y mostrarlos en HTML. Es el patrón más repetido del backend.

El patrón: SELECT + foreach + HTML

<?php
// usuarios/listar.php: listar todos los usuarios
require_once '../conexion.php';

// 1. PREPARAR la consulta (con prepared statement)
$sql = 'SELECT id, nombre, email, edad, activo FROM usuarios ORDER BY id DESC';
$stmt = $pdo->prepare($sql);
$stmt->execute();

// 2. OBTENER todos los resultados
$usuarios = $stmt->fetchAll();

// 3. MOSTRAR en HTML
?>
<h1>Lista de usuarios (<?= count($usuarios) ?>)</h1>
<table>
  <thead>
    <tr><th>ID</th><th>Nombre</th><th>Email</th><th>Edad</th><th>Activo</th><th>Acciones</th></tr>
  </thead>
  <tbody>
    <?php foreach ($usuarios as $u): ?>
      <tr>
        <td><?= htmlspecialchars($u['id']) ?></td>
        <td><?= htmlspecialchars($u['nombre']) ?></td>
        <td><?= htmlspecialchars($u['email']) ?></td>
        <td><?= htmlspecialchars($u['edad']) ?></td>
        <td><?= $u['activo'] ? '✅' : '❌' ?></td>
        <td>
          <a href="ver.php?id=<?= $u['id'] ?>">Ver</a> |
          <a href="editar.php?id=<?= $u['id'] ?>">Editar</a> |
          <a href="eliminar.php?id=<?= $u['id'] ?>">Eliminar</a>
        </td>
      </tr>
    <?php endforeach; ?>
  </tbody>
</table>
<a href="crear.php">+ Crear nuevo usuario</a>

Listar usuarios: SELECT + foreach + HTML

⭐ 3 reglas del READ: SIEMPRE pasa el id por URL con ?id=X (GET), NUNCA por sesión o cookie sin validación. Y SIEMPRE sanitiza el output con htmlspecialchars antes de imprimir, para evitar XSS. Y SIEMPRE valida que el id sea numérico antes de usarlo: $id = (int) $_GET['id'].
🎬
Video del instructor

El administrador aún no ha insertado un video para esta sección.

2.5.3 CREATE: insertar nuevos registros

Insertar es la operación que añade nuevos registros. Viene siempre de un formulario (POST) que el usuario llena. PHP valida los datos, los inserta con INSERT, y redirige al listado (patrón PRG del Tema 2.3).

<?php
// usuarios/crear.php: formulario que crea un nuevo usuario
require_once '../conexion.php';

$errores = [];
$nombre = $email = $edad = '';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
  // 1. LEER y SANITIZAR inputs
  $nombre = trim($_POST['nombre'] ?? '');
  $email = trim($_POST['email'] ?? '');
  $edad = trim($_POST['edad'] ?? '');

  // 2. VALIDAR
  if (empty($nombre)) $errores[] = 'El nombre es obligatorio.';
  if (!filter_var($email, FILTER_VALIDATE_EMAIL)) $errores[] = 'Email inválido.';
  if (!filter_var($edad, FILTER_VALIDATE_INT, ['options' => ['min_range' => 0, 'max_range' => 120]])) {
    $errores[] = 'Edad debe ser entre 0 y 120.';
  }

  // 3. Si NO hay errores, INSERT con prepared statement
  if (empty($errores)) {
    try {
      $sql = 'INSERT INTO usuarios (nombre, email, edad) VALUES (?, ?, ?)';
      $stmt = $pdo->prepare($sql);
      $stmt->execute([$nombre, $email, $edad]);
      // Patrón PRG: redirigir al listado
      header('Location: listar.php?msg=creado');
      exit;
    } catch (PDOException $e) {
      // Email duplicado (UNIQUE constraint) o similar
      if ($e->getCode() == 23000) {
        $errores[] = 'Ya existe un usuario con ese email.';
      } else {
        $errores[] = 'Error al guardar. Intenta más tarde.';
        error_log($e->getMessage());  // log técnico, no al usuario
      }
    }
  }
}
?>
<h1>Crear usuario</h1>
<?php if (!empty($errores)): ?>
  <ul class="errores"><?php foreach ($errores as $e) echo "<li>$e</li>"; ?></ul>
<?php endif; ?>
<form method="post">
  <input name="nombre" value="<?= htmlspecialchars($nombre) ?>" placeholder="Nombre">
  <input name="email" type="email" value="<?= htmlspecialchars($email) ?>" placeholder="Email">
  <input name="edad" type="number" value="<?= htmlspecialchars($edad) ?>" placeholder="Edad">
  <button type="submit">Guardar</button>
  <a href="listar.php">Cancelar</a>
</form>

Crear usuario: formulario + validación + INSERT + PRG

⭐ Manejo elegante de errores: El catch (PDOException $e) con código 23000 captura errores de UNIQUE constraint (email duplicado). Es importante: si no lo manejas, el usuario ve un error genérico 'SQLSTATE[23000]' que no entiende. Manejar errores de forma AMIGABLE es la diferencia entre un junior y un senior.
🎬
Video del instructor

El administrador aún no ha insertado un video para esta sección.

2.5.4 UPDATE: editar registros existentes

Editar es similar a crear, pero con dos diferencias: (1) precarga los valores actuales en el formulario, (2) usa UPDATE en vez de INSERT. La estructura de validación es idéntica.

<?php
// usuarios/editar.php: editar un usuario existente
require_once '../conexion.php';

// 1. SIEMPRE validar el id de la URL
$id = (int)($_GET['id'] ?? 0);
if ($id <= 0) { die('ID inválido'); }

$errores = [];

// 2. Si es GET, CARGAR los datos actuales para pre-rellenar el formulario
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
  $stmt = $pdo->prepare('SELECT * FROM usuarios WHERE id = ?');
  $stmt->execute([$id]);
  $usuario = $stmt->fetch();
  if (!$usuario) { die('Usuario no encontrado'); }
  $nombre = $usuario['nombre'];
  $email = $usuario['email'];
  $edad = $usuario['edad'];
}

// 3. Si es POST, VALIDAR y ACTUALIZAR
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
  $nombre = trim($_POST['nombre'] ?? '');
  $email = trim($_POST['email'] ?? '');
  $edad = trim($_POST['edad'] ?? '');
  $id = (int)($_POST['id'] ?? 0);  // el id viene hidden en el form

  // ... validaciones (igual que en crear.php) ...
  if (empty($errores)) {
    $sql = 'UPDATE usuarios SET nombre = ?, email = ?, edad = ? WHERE id = ?';
    $stmt = $pdo->prepare($sql);
    $stmt->execute([$nombre, $email, $edad, $id]);
    header('Location: listar.php?msg=editado');
    exit;
  }
}
?>
<h1>Editar usuario #<?= $id ?></h1>
<form method="post">
  <input type="hidden" name="id" value="<?= $id ?>">
  <input name="nombre" value="<?= htmlspecialchars($nombre) ?>">
  <input name="email" type="email" value="<?= htmlspecialchars($email) ?>">
  <input name="edad" type="number" value="<?= htmlspecialchars($edad) ?>">
  <button type="submit">Actualizar</button>
  <a href="listar.php">Cancelar</a>
</form>

Editar usuario: cargar + validar + UPDATE + PRG

⭐ hidden no es seguro: Detalle de seguridad: el campo <input type='hidden' name='id'> en el formulario es NECESARIO para saber qué registro actualizar. Pero NUNCA confíes en él: el usuario puede cambiarlo con DevTools. SIEMPRE valida que (int) sea positivo y que el registro exista. Es defensa en profundidad.
🎬
Video del instructor

El administrador aún no ha insertado un video para esta sección.

2.5.5 DELETE: eliminar con confirmación

Eliminar es la operación más PELIGROSA: un error aquí puede borrar datos irrecuperables. Por eso siempre: (1) pide confirmación, (2) usa POST (no GET) para evitar borrados accidentales por un enlace mal clickeado, (3) considera 'soft delete' en producción.

<?php
// usuarios/eliminar.php: eliminar con confirmación
require_once '../conexion.php';

// 1. Validar id
$id = (int)($_GET['id'] ?? 0);
if ($id <= 0) { die('ID inválido'); }

// 2. Si es GET, MOSTRAR página de confirmación
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
  $stmt = $pdo->prepare('SELECT nombre, email FROM usuarios WHERE id = ?');
  $stmt->execute([$id]);
  $usuario = $stmt->fetch();
  if (!$usuario) { die('Usuario no encontrado'); }
  ?>
  <h1>¿Eliminar usuario?</h1>
  <p>Estás a punto de eliminar a <strong><?= htmlspecialchars($usuario['nombre']) ?></strong> (<?= htmlspecialchars($usuario['email']) ?>).</p>
  <p>Esta acción NO se puede deshacer.</p>
  <form method="post">
    <input type="hidden" name="id" value="<?= $id ?>">
    <button type="submit" name="confirmar" value="si">Sí, eliminar</button>
    <a href="listar.php">Cancelar</a>
  </form>
  <?php
}

// 3. Si es POST (usuario confirmó), EJECUTAR el DELETE
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['confirmar'] ?? '') === 'si') {
  $id = (int)($_POST['id'] ?? 0);
  $stmt = $pdo->prepare('DELETE FROM usuarios WHERE id = ?');
  $stmt->execute([$id]);
  header('Location: listar.php?msg=eliminado');
  exit;
}
?>

<!-- Soft delete (alternativa PROFESIONAL): -->
<!-- En vez de DELETE, hacer UPDATE activo = FALSE: -->
<?php
// $stmt = $pdo->prepare('UPDATE usuarios SET activo = FALSE WHERE id = ?');
// $stmt->execute([$id]);
// Y en los SELECT, filtrar WHERE activo = TRUE
?>

Eliminar con confirmación + soft delete

⭐ Soft delete > hard delete: Soft delete es PROFESIONAL: en vez de borrar el registro (irrecuperable), marcas activo = FALSE. Los listados filtran WHERE activo = TRUE, así que el usuario 'no ve' los eliminados, pero los datos siguen ahí para auditoría o recuperación. Es el patrón usado por Gmail (papelera), GitHub (issues cerrados), y casi todas las apps serias.
🎬
Video del instructor

El administrador aún no ha insertado un video para esta sección.

2.5.6 Paginación: el patrón que escala

Si tu tabla tiene 1 millón de usuarios y los muestras todos en una sola página, el navegador tarda 30 segundos en renderizar y el servidor se queda sin memoria. La solución: paginación. Muestras solo 20-50 registros por página, con enlaces 'Anterior / Siguiente'.

<?php
// usuarios/listar.php CON PAGINACIÓN
require_once '../conexion.php';

// 1. Calcular total de registros
$total = $pdo->query('SELECT COUNT(*) FROM usuarios')->fetchColumn();
$por_pagina = 20;
$total_paginas = ceil($total / $por_pagina);

// 2. Obtener la página actual (con validación)
$pagina = max(1, min($total_paginas, (int)($_GET['pagina'] ?? 1)));
$offset = ($pagina - 1) * $por_pagina;

// 3. SELECT solo los registros de esta página
$stmt = $pdo->prepare('SELECT id, nombre, email, edad FROM usuarios ORDER BY id DESC LIMIT ? OFFSET ?');
$stmt->execute([$por_pagina, $offset]);
$usuarios = $stmt->fetchAll();

// 4. Mostrar + enlaces de paginación
?>
<h1>Usuarios (página <?= $pagina ?> de <?= $total_paginas ?>)</h1>
<table><?php foreach ($usuarios as $u): ?><tr>
  <td><?= htmlspecialchars($u['nombre']) ?></td>
  <td><?= htmlspecialchars($u['email']) ?></td>
</tr><?php endforeach; ?></table>

<div class="paginacion">
  <?php if ($pagina > 1): ?>
    <a href="?pagina=<?= $pagina - 1 ?>">← Anterior</a>
  <?php endif; ?>
  <?php for ($i = 1; $i <= $total_paginas; $i++): ?>
    <a href="?pagina=<?= $i ?>" <?= $i === $pagina ? 'class="activa"' : '' ?>><?= $i ?></a>
  <?php endfor; ?>
  <?php if ($pagina < $total_paginas): ?>
    <a href="?pagina=<?= $pagina + 1 ?>">Siguiente →</a>
  <?php endif; ?>
</div>

Paginación con LIMIT y OFFSET

⭐ OFFSET para el 95%, cursor para el 5%: OFFSET funciona bien hasta ~100,000 registros. Para millones, se vuelve lento porque la BD tiene que 'saltar' todos los registros anteriores. La solución profesional es 'paginación por cursor' (WHERE id < último_id_seen), que es instantánea incluso con millones de registros. Pero para el 95% de apps, OFFSET es suficiente.
🎬
Video del instructor

El administrador aún no ha insertado un video para esta sección.

📚 Contenido ampliado

Material adicional, referencias externas verificadas y ejemplos extendidos.

🤖 AI Mission

CRUD se aprende construyendo uno, no leyendo sobre CRUD.

Misión: Construye un CRUD completo de gestión de PRODUCTOS (no usuarios) con PHP, MySQL y PDO. Implementa: listar paginado, crear, editar, eliminar con confirmación. Después, pídele a la IA que audite tu código y te sugiera 2 mejoras (UX o seguridad). Anota en tu cuaderno: ¿qué parte fue la más difícil? ¿qué te hizo perder más tiempo?

Pasos sugeridos

  1. Crea la tabla 'productos' en MySQL: id, nombre, descripcion, precio, stock, categoria, creado_en.
  2. Inserta 25 productos de prueba (5 categorías diferentes).
  3. Crea productos/listar.php con paginación (10 por página).
  4. Crea productos/crear.php con formulario + validación + INSERT.
  5. Crea productos/editar.php con carga previa + UPDATE.
  6. Crea productos/eliminar.php con confirmación POST.
  7. Estiliza con CSS externo: tabla limpia, formulario ordenado, mensajes de éxito/error.
  8. Prueba el flujo: crear 3 productos, editar 1, eliminar 1, navegar páginas.
  9. Intenta SQL injection: en el buscador, escribe ' OR '1'='1. ¿Tu prepared statement lo bloquea?
  10. Pídele a la IA: 'Tengo este CRUD de productos. Sugiere 2 mejoras: UX (búsqueda, orden, filtros) o seguridad (CSRF, rate limit). NO me des código, dime QUÉ agregar.'
  11. Aplica 2 mejoras. Vuelve a probar.
  12. Anota: 3 cosas que aprendiste HACIENDO que ningún tutorial te enseñó.

📓 Entregable: Capturas del CRUD funcionando (listar, crear, editar, eliminar), intento de SQL injection bloqueado, código de los 4 archivos PHP, y media página de cuaderno con tu reflexión.

🚫 Errores típicos de razonamiento

Error 1: Pasar el id de eliminación por GET (un enlace).
Por qué: Si pones <a href='eliminar.php?id=5'>, un usuario puede borrar registros sin querer haciendo clic, o un bot de Google puede seguir el enlace y borrar tu BD. SIEMPRE usa POST para acciones destructivas, con un formulario que pida confirmación.
Error 2: No validar que el id de la URL existe antes de UPDATE/DELETE.
Por qué: Si el usuario edita la URL manualmente y pone id=99999, tu UPDATE no actualizará nada (0 filas afectadas) pero tú pensarás que sí. O peor: tu DELETE borrará 0 registros y mostrarás 'eliminado correctamente' cuando no se eliminó nada. SIEMPRE verifica con un SELECT primero, o al menos chequea $stmt->rowCount() > 0 después.
Error 3: Mostrar IDs de URL sin filtrar ni validar.
Por qué: Si recibes ?id=X y X es 'abc' o un string malicioso, tu query puede romperse. SIEMPRE (int) $_GET['id'] para forzar a entero, y verifica que sea > 0 antes de usarlo. Es una línea de código que te ahorra horas de debugging.
Error 4: Confiar en el input del usuario y no validar antes de UPDATE/INSERT.
Por qué: El usuario puede enviar precio = -1000, o stock = 'mucho', o email con un script. SIEMPRE valida con filter_var, ctype_digit, o verificaciones explícitas antes de hacer el INSERT/UPDATE. Tu capa de validación es la primera línea de defensa.
Error 5: Olvidar el patrón PRG y dejar el formulario 're-enviable'.
Por qué: Si después de INSERT/UPDATE/DELETE muestras el mensaje de éxito SIN redirigir, el usuario ve '¿Reenviar formulario?' al recargar, y si confirma, se crea otro registro duplicado. SIEMPRE header('Location: ...') + exit después de una operación que modifica datos.

🧪 Laboratorio práctico

El CRUD no se hace una vez: se hace 100 veces. Por eso se aprende bien la primera.

Laboratorio: CRUD completo de gestión de productos

Objetivo: Construir un CRUD profesional de productos con PHP, MySQL y PDO. La aplicación debe permitir: listar paginado, crear con validación, editar con carga previa, eliminar con confirmación. Listo para subir a un hosting con CPANEL.

Pasos

  1. Crea la tabla 'productos' en MySQL: id, nombre, descripcion, precio, stock, categoria, creado_en.
  2. Inserta 20+ productos de prueba (5 categorías).
  3. Estructura de archivos: productos/listar.php, crear.php, editar.php, eliminar.php, conexion.php, estilos.css.
  4. listar.php: SELECT con paginación (10 por página), tabla con columnas, enlaces a Ver/Editar/Eliminar.
  5. crear.php: formulario + validación (precio > 0, stock >= 0) + INSERT + PRG.
  6. editar.php: cargar datos del id + formulario pre-rellenado + UPDATE + PRG.
  7. eliminar.php: GET muestra confirmación, POST ejecuta DELETE. Soft delete con activo=FALSE.
  8. Mensajes de éxito: ?msg=creado / editado / eliminado en listar.php muestra banner verde.
  9. Estiliza con CSS: tabla limpia, formulario ordenado, errores en rojo, éxito en verde.
  10. Prueba el flujo: crear 3, editar 1, eliminar 1, navegar páginas, intentar SQL injection.
  11. Sube a tu hosting con CPANEL: archivos, BD, conexion.php actualizado.
  12. Verifica en HTTPS. Comparte la URL con un compañero y pídele feedback.
  13. Haz commit: 'feat: CRUD de productos con paginación, soft delete y deploy'.

📓 Entregable: URL pública del CRUD funcionando, capturas de las 4 operaciones, intento de SQL injection bloqueado, código de los 4 archivos PHP, y commit en Git.

📓 Tu cuaderno: Anota pseudocódigo, diagramas, errores que encontraste y respuestas a "explica sin código". La escritura manual refuerza tu razonamiento. Tu profesor puede pedirte que subas fotos de páginas específicas.