Práctica 08: CRUD de Alumnos con PHP y PDO

Objetivo

Desarrollar una aplicación web CRUD (Create, Read, Update, Delete) en PHP utilizando PDO para la conexión a una base de datos MySQL. La aplicación permite gestionar el registro de alumnos de una escuela: matrícula, nombre, apellidos, semestre y especialidad.


Requisitos previos

  • Servidor local con PHP (XAMPP, WAMP, Laragon, etc.)
  • MySQL / MariaDB en ejecución
  • Editor de código
  • Conocimientos básicos de HTML, SQL y PHP

Estructura del proyecto

crud_alumnos/
├── config/
│   └── conexion.php      # Conexión PDO a la base de datos
├── css/
│   └── estilos.css       # Hoja de estilos
├── index.php             # Listar alumnos (Read)
├── crear.php             # Formulario de registro (Create - vista)
├── guardar.php           # Insertar en la BD (Create - acción)
├── editar.php            # Formulario de edición (Update - vista)
├── actualizar.php        # Actualizar en la BD (Update - acción)
└── eliminar.php          # Borrar registro (Delete)

Idea clave: el proyecto separa las vistas (formularios HTML) de las acciones (scripts PHP que solo procesan datos y redirigen). Cada operación CRUD tiene su propio archivo, lo que facilita su mantenimiento.


Paso 1 — Crear la base de datos

En phpMyAdmin (o el cliente MySQL de tu preferencia) ejecuta:

CREATE DATABASE IF NOT EXISTS escuela
CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;

USE escuela;

CREATE TABLE alumnos (
    id INT AUTO_INCREMENT PRIMARY KEY,
    matricula VARCHAR(10) NOT NULL,
    nombre VARCHAR(50) NOT NULL,
    apellidos VARCHAR(100) NOT NULL,
    semestre INT NOT NULL,
    especialidad VARCHAR(50) NOT NULL
);

Partes importantes

  • utf8mb4 permite guardar acentos y caracteres especiales sin problemas.
  • AUTO_INCREMENT PRIMARY KEY genera un id único para cada alumno automáticamente.
  • Los tipos VARCHAR se dimensionan según el contenido esperado.

Puedes insertar algunos datos de prueba:

INSERT INTO alumnos (matricula, nombre, apellidos, semestre, especialidad)
VALUES
    ('2024001', 'Ana', 'García López', 3, 'Programación'),
    ('2024002', 'Luis', 'Martínez Pérez', 1, 'Contabilidad'),
    ('2024003', 'María', 'Hernández Ruiz', 5, 'Administración');

Paso 2 — Conexión a la base de datos con PDO

Crear config/conexion.php:

<?php

$host = "127.0.0.1";
$dbname = "escuela";
$user = "root";
$password = "TU_PASSWORD";

try {

    $pdo = new PDO(
        "mysql:host=$host;dbname=$dbname;charset=utf8mb4",
        $user,
        $password
    );

    $pdo->setAttribute(
        PDO::ATTR_ERRMODE,
        PDO::ERRMODE_EXCEPTION
    );

} catch (PDOException $e) {

    die("Error de conexión: " . $e->getMessage());
}

Partes importantes

  • PDO (PHP Data Objects) es una capa de abstracción que permite conectarse a distintos gestores de base de datos con la misma interfaz.
  • El primer argumento del constructor es el DSN (Data Source Name): indica el motor (mysql), el host, la base de datos y el charset.
  • PDO::ATTR_ERRMODE = PDO::ERRMODE_EXCEPTION hace que los errores de SQL lancen excepciones, en lugar de fallar silenciosamente. Esto facilita detectar errores durante el desarrollo.
  • El try/catch captura un posible fallo de conexión y termina el script con un mensaje, evitando que el resto de la página se ejecute sin base de datos.
  • En todos los demás archivos solo se incluye con require_once, obteniendo la variable $pdo lista para usarse:
require_once "config/conexion.php";

Paso 3 — Listar alumnos (Read)

Crear index.php. Este archivo hace dos cosas: primero consulta la base de datos y después imprime los resultados en una tabla HTML.

<?php

require_once "config/conexion.php";

$sql = "SELECT * FROM alumnos ORDER BY id DESC";

$stmt = $pdo->query($sql);

$alumnos = $stmt->fetchAll(PDO::FETCH_ASSOC);

?>

<!DOCTYPE html>
<html lang="es">

<head>
    <meta charset="UTF-8">
    <title>Lista de alumnos</title>
    <link rel="stylesheet" href="css/estilos.css">
</head>

<body>

    <h1>Lista de alumnos</h1>
    <a href="crear.php">Registrar alumno</a>
    <br><br>
    <table border="1">
        <thead>
            <tr>
                <th>ID</th>
                <th>Matrícula</th>
                <th>Nombre</th>
                <th>Apellidos</th>
                <th>Semestre</th>
                <th>Especialidad</th>
                <th>Acciones</th>
            </tr>
        </thead>
        <tbody>

            <?php foreach ($alumnos as $alumno): ?>

            <tr>
                <td><?= $alumno['id'] ?></td>
                <td><?= htmlspecialchars($alumno['matricula']) ?></td>
                <td><?= htmlspecialchars($alumno['nombre']) ?></td>
                <td><?= htmlspecialchars($alumno['apellidos']) ?></td>
                <td><?= $alumno['semestre'] ?></td>
                <td><?= htmlspecialchars($alumno['especialidad']) ?></td>
                <td>
                    <a href="editar.php?id=<?= $alumno['id'] ?>">
                        Editar
                    </a>
                    |
                    <a href="eliminar.php?id=<?= $alumno['id'] ?>"
                    onclick="return confirm('¿Deseas eliminar este alumno?')">
                        Eliminar
                    </a>
                </td>
            </tr>

            <?php endforeach; ?>

        </tbody>
    </table>
</body>
</html>

Partes importantes

  • $pdo->query($sql) ejecuta el SELECT directamente. Es seguro usarlo aquí porque la consulta no recibe datos del usuario.
  • fetchAll(PDO::FETCH_ASSOC) devuelve todas las filas como un arreglo de arreglos asociativos (las llaves son los nombres de las columnas).
  • foreach ... endforeach es la sintaxis alternativa de PHP, muy cómoda para mezclar con HTML porque evita llaves { } entre etiquetas.
  • <?= $variable ?> es la forma corta de <?php echo $variable ?>.
  • htmlspecialchars() convierte caracteres como <, > y " en entidades HTML. Es indispensable para evitar ataques XSS (inyección de código en la página) si un campo contiene HTML o JavaScript.
  • Los enlaces Editar/Eliminar envían el id del registro por la URL: editar.php?id=3. Ese valor se leerá con $_GET['id'].
  • onclick="return confirm(...)" pide confirmación en el navegador antes de ejecutar el borrado.

Paso 4 — Registrar alumno (Create)

Se divide en dos archivos: el formulario (crear.php) y el script que inserta (guardar.php).

4.1 Formulario — crear.php

<!DOCTYPE html>
<html lang="es">

<head>
    <meta charset="UTF-8">
    <title>Registrar alumno</title>
    <link rel="stylesheet" href="css/estilos.css">
</head>

<body>
    <h1>Registrar alumno</h1>

    <form action="guardar.php" method="POST">

        <label>Matrícula:</label>
        <br>
        <input type="text" name="matricula" required>
        <br><br>
        <label>Nombre:</label>
        <br>
        <input type="text" name="nombre" required>
        <br><br>
        <label>Apellidos:</label>
        <br>
        <input type="text" name="apellidos" required>
        <br><br>
        <label>Semestre:</label>
        <br>
        <input type="number" name="semestre" min="1" max="6" required>
        <br><br>
        <label>Especialidad:</label>
        <br>
        <select name="especialidad" required>
            <option value="">Selecciona una especialidad</option>
            <option value="Programación">Programación</option>
            <option value="Contabilidad">Contabilidad</option>
            <option value="Administración">Administración</option>
            <option value="Ventas">Ventas</option>
            <option value="Construcción">Construcción</option>
        </select>
        <br><br>
        <button type="submit">Guardar alumno</button>
    </form>
    <br>
    <a href="index.php">Regresar</a>
</body>
</html>

Partes importantes

  • method="POST" envía los datos en el cuerpo de la petición HTTP (no en la URL). Se usa POST porque el formulario modifica el estado del servidor.
  • El atributo name de cada input define la llave con la que se leerá el valor en PHP: name="matricula" → $_POST['matricula'].
  • required, min y max son validaciones del lado del cliente. Ayudan a la experiencia de usuario, pero no sustituyen la validación en el servidor.
  • El <select> restringe la especialidad a un catálogo fijo de opciones.

4.2 Insertar en la base de datos — guardar.php

<?php

require_once "config/conexion.php";

$matricula    = $_POST['matricula'];
$nombre       = $_POST['nombre'];
$apellidos    = $_POST['apellidos'];
$semestre     = $_POST['semestre'];
$especialidad = $_POST['especialidad'];

$sql = "INSERT INTO alumnos
        (matricula, nombre, apellidos, semestre, especialidad)
        VALUES
        (:matricula, :nombre, :apellidos, :semestre, :especialidad)";

$stmt = $pdo->prepare($sql);

$stmt->execute([
    ':matricula'    => $matricula,
    ':nombre'       => $nombre,
    ':apellidos'    => $apellidos,
    ':semestre'     => $semestre,
    ':especialidad' => $especialidad
]);

header("Location: index.php");
exit;

Partes importantes

  • Consulta preparada: en lugar de concatenar valores dentro del SQL, se usan marcadores con nombre (:matricula, etc.).
  • $pdo->prepare($sql) compila la consulta; $stmt->execute([...]) envía los valores por separado. Esto protege contra inyección SQL, porque los datos nunca se interpretan como parte del comando SQL.
  • header("Location: index.php") redirige al listado después de insertar. exit detiene el script inmediatamente (obligatorio tras un Location).
  • Este patrón (procesar el POST y luego redirigir) se conoce como Post/Redirect/Get y evita que al refrescar la página el navegador reenvíe el formulario duplicando el registro.

Paso 5 — Editar alumno (Update)

También se divide en dos archivos: editar.php carga los datos actuales en un formulario y actualizar.php ejecuta el UPDATE.

5.1 Formulario precargado — editar.php

<?php

require_once "config/conexion.php";

$id = $_GET['id'];

$sql = "SELECT * FROM alumnos WHERE id = :id";
$stmt = $pdo->prepare($sql);
$stmt->execute([
    ':id' => $id
]);

$alumno = $stmt->fetch(PDO::FETCH_ASSOC);

if (!$alumno) {
    die("Alumno no encontrado");
}

?>

<!DOCTYPE html>
<html lang="es">

<head>
    <meta charset="UTF-8">
    <title>Editar alumno</title>
    <link rel="stylesheet" href="css/estilos.css">
</head>

<body>
    <h1>Editar alumno</h1>
    <form action="actualizar.php" method="POST">
        <input type="hidden" name="id" value="<?= $alumno['id'] ?>">
        <label>Matrícula:</label>
        <br>
        <input type="text" name="matricula"
            value="<?= htmlspecialchars($alumno['matricula']) ?>" required>
        <br><br>
        <label>Nombre:</label>
        <br>
        <input type="text" name="nombre"
            value="<?= htmlspecialchars($alumno['nombre']) ?>" required>
        <br><br>
        <label>Apellidos:</label>
        <br>
        <input type="text" name="apellidos"
            value="<?= htmlspecialchars($alumno['apellidos']) ?>" required>
        <br><br>
        <label>Semestre:</label>
        <br>
        <input type="number" name="semestre" min="1" max="6"
            value="<?= $alumno['semestre'] ?>" required>
        <br><br>
        <label>Especialidad:</label>
        <br>
        <input type="text" name="especialidad"
            value="<?= htmlspecialchars($alumno['especialidad']) ?>" required>
        <br><br>
        <button type="submit">Actualizar</button>
    </form>
    <br>
    <a href="index.php">Regresar</a>
</body>
</html>

Partes importantes

  • $_GET['id'] recibe el identificador que viaja en la URL (editar.php?id=3).
  • La consulta usa un marcador :id: aunque el valor venga de la URL (controlado por el usuario), la consulta preparada lo trata siempre como dato, nunca como SQL.
  • fetch() (singular) devuelve una sola fila. Si no existe el registro retorna false, y el script termina con die().
  • El campo oculto <input type="hidden" name="id"> es fundamental: es la forma de decirle a actualizar.php qué registro debe modificar.
  • Los value="<?= ... ?>" precargan el formulario con los datos actuales. Nuevamente se usa htmlspecialchars() para no romper el HTML si el valor contiene comillas.

5.2 Actualizar en la base de datos — actualizar.php

<?php

require_once "config/conexion.php";

$id           = $_POST['id'];
$matricula    = $_POST['matricula'];
$nombre       = $_POST['nombre'];
$apellidos    = $_POST['apellidos'];
$semestre     = $_POST['semestre'];
$especialidad = $_POST['especialidad'];

$sql = "UPDATE alumnos SET
            matricula    = :matricula,
            nombre       = :nombre,
            apellidos    = :apellidos,
            semestre     = :semestre,
            especialidad = :especialidad
        WHERE id = :id";

$stmt = $pdo->prepare($sql);

$stmt->execute([
    ':matricula'    => $matricula,
    ':nombre'       => $nombre,
    ':apellidos'    => $apellidos,
    ':semestre'     => $semestre,
    ':especialidad' => $especialidad,
    ':id'           => $id
]);

header("Location: index.php");
exit;

Partes importantes

  • El WHERE id = :id es crítico: sin él, el UPDATE modificaría todos los registros de la tabla.
  • El id llega por $_POST gracias al campo oculto del formulario.
  • La estructura es idéntica a guardar.php: preparar → ejecutar → redirigir.

Paso 6 — Eliminar alumno (Delete)

Crear eliminar.php. No necesita formulario: recibe el id por la URL y borra el registro directamente (la confirmación ya la hizo el confirm() de JavaScript en index.php).

<?php

require_once "config/conexion.php";

$id = $_GET['id'];

$sql = "DELETE FROM alumnos WHERE id = :id";

$stmt = $pdo->prepare($sql);

$stmt->execute([
    ':id' => $id
]);

header("Location: index.php");
exit;

Partes importantes

  • Igual que en el UPDATE, el WHERE id = :id delimita un solo registro.
  • Nota de diseño: en aplicaciones reales el borrado por GET es riesgoso (un rastreador o pre-carga del navegador podría ejecutarlo). Lo correcto es hacerlo con un formulario POST. Aquí se usa GET por simplicidad didáctica, pero conviene mencionarlo en clase.

Paso 7 — Estilos (opcional)

El proyecto incluye css/estilos.css con estilos para el contenedor, la tabla, los botones (.btn-primary, .btn-danger, etc.) y formularios. También define clases pensadas para los retos (.buscador, .container, .form-group), por lo que al aplicar class="container" al <body> o un <div> principal, la apariencia mejora sin tocar más CSS.


Flujo completo de la aplicación

index.php ──Registrar──▶ crear.php ──POST──▶ guardar.php ──▶ index.php
   │
   ├──Editar──▶ editar.php ──POST──▶ actualizar.php ──▶ index.php
   │
   └──Eliminar──▶ eliminar.php ──▶ index.php

Conceptos aprendidos

Concepto Dónde se usa
Conexión PDO y DSN config/conexion.php
query() vs prepare()/execute() index.php vs resto
Marcadores con nombre (:campo) guardar.php, editar.php, actualizar.php, eliminar.php
$_GET y $_POST Enlaces con ?id=, formularios
fetch() / fetchAll() editar.php / index.php
htmlspecialchars() contra XSS Salidas HTML
Post/Redirect/Get guardar.php, actualizar.php, eliminar.php

Retos adicionales

Los siguientes retos deberás implementarlos desde cero, modificando los archivos del proyecto. Solo se indican los requisitos y algunas pistas.

Reto 1 — Búsqueda

Agregar en index.php un formulario de búsqueda que permita encontrar alumnos por nombre, apellidos o matrícula.

  • El formulario envía el término por GET (index.php?q=texto).
  • La consulta SQL debe usar WHERE ... LIKE con comodines %.
  • Pista: el comodín va en el valor, no en el SQL: '%' . $q . '%' pasado como parámetro del marcador.
  • Pista: para buscar en varias columnas se puede usar OR o CONCAT(nombre, ' ', apellidos) LIKE :q.
  • Si no hay término de búsqueda, se muestra la lista completa.

Reto 2 — Filtrar por especialidad

Agregar en index.php un <select> con las especialidades disponibles que filtre la tabla al elegir una opción.

  • El filtro puede combinarse con la búsqueda del Reto 1 (dos condiciones en el WHERE unidas con AND).
  • Pista: la consulta se arma dinámicamente; solo agrega el AND especialidad = :especialidad cuando el filtro tenga valor.
  • El <select> debe recordar la opción elegida (selected cuando coincide con $_GET).
  • Incluir una opción "Todas" que quite el filtro.

Reto 3 — Validar matrícula duplicada

Evitar que se registren dos alumnos con la misma matrícula.

  • Antes del INSERT en guardar.php, hacer un SELECT para comprobar si la matrícula ya existe.
  • Si existe, redirigir a crear.php mostrando un mensaje de error y sin perder los datos que el usuario ya escribió.
  • Repetir la validación en actualizar.php, pero excluyendo al propio alumno: WHERE matricula = :matricula AND id != :id.
  • Pista (defensa en profundidad): además de la validación en PHP, agrega una restricción en la base de datos: ALTER TABLE alumnos ADD UNIQUE (matricula);

Reto 4 — Mensajes mediante $_GET

Mostrar mensajes de éxito/error en la interfaz después de cada operación.

  • En lugar de redirigir a index.php "a secas", hacerlo con un parámetro: header("Location: index.php?msg=creado"), ?msg=actualizado, ?msg=eliminado, ?msg=error, etc.
  • En index.php, leer $_GET['msg'] y mostrar un aviso (<div class="alerta">) con el texto correspondiente.
  • Usar una estructura tipo switch o arreglo asociativo para mapear el código de mensaje a su texto (evita imprimir directamente el valor de $_GET).
  • Aplicar el mismo mecanismo para el error del Reto 3 (por ejemplo crear.php?error=duplicada).
  • Pista: los mensajes por $_GET son una versión sencilla del patrón flash messages que usan los frameworks con sesiones.

Rúbrica sugerida

Criterio Puntos
Base de datos creada correctamente 10
Conexión PDO funcional 10
Listado de alumnos 15
Registro de alumnos 15
Edición y actualización 15
Eliminación con confirmación 10
Consultas preparadas y htmlspecialchars() 10
Retos adicionales (5 c/u) 20
Total 100