Apuntes DAM
Tema 5 de 7DWES · 2º DAW

Desarrollo Web en Entorno Servidor

Acceso a Datos con PDO

Conexión con PDO, consultas preparadas e inyección SQL, lectura de resultados, CRUD, paginación con LIMIT, transacciones, repositorios y ORM, con retos sobre una base de datos real en memoria.

60 min lecturaIntermedio

1. PDO: una puerta para cualquier base de datos

Casi todas las aplicaciones web guardan sus datos en una base de datos relacional, y PHP se comunica con ellas a través de PDO (PHP Data Objects). PDO ofrece la misma forma de trabajar con MySQL, MariaDB, PostgreSQL, SQLite o SQL Server: solo cambia la cadena de conexión, llamada DSN. Existe también la extensión mysqli, pero solo sirve para MySQL; PDO es la opción recomendada.

src/conexion.php
1<?php
2function conectar(): PDO
3{
4    static $pdo = null;              // una única conexión por petición
5    return $pdo ??= new PDO(
6        'mysql:host=db;dbname=tienda;charset=utf8mb4',
7        getenv('DB_USER'),
8        getenv('DB_PASS'),
9        [
10            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,         // los errores lanzan excepciones
11            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,    // filas como arrays asociativos
12            PDO::ATTR_EMULATE_PREPARES => false,                 // consultas preparadas reales
13        ],
14    );
15}

Tres detalles de ese código evitan muchos disgustos. El modo de errores por excepciones hace que un fallo de SQL no pase desapercibido. El charset=utf8mb4 garantiza que las tildes y los emojis se guarden bien. Y las credenciales no están escritas en el código: se leen de variables de entorno (o de un fichero .env que no se sube a Git), porque el código acaba en repositorios, copias y portátiles.

2. Consultas preparadas y la inyección SQL

La inyección SQL lleva décadas en los primeros puestos de las vulnerabilidades web, y se produce siempre de la misma forma: construir una consulta pegando texto que viene del usuario. Si alguien escribe ' OR '1'='1 en el campo de usuario de un login mal hecho, la condición se cumple para todas las filas y entra sin contraseña. Con variantes más elaboradas puede leer tablas enteras o borrarlas.

Una consulta preparada es un formulario con casillas

Primero, la plantilla
Envías a la base de datos la consulta con huecos: SELECT … WHERE email = ?. Ella la analiza y la deja lista.
Después, los datos
Rellenas los huecos. La base de datos los trata siempre como datos, nunca como parte del SQL.
Resultado
Aunque el usuario escriba comillas o palabras de SQL, no pueden cambiar el significado de la consulta.
php
1// ✗ Vulnerable: el texto del usuario se convierte en SQL
2$pdo->query("SELECT * FROM usuarios WHERE email = '$email'");
3
4// ✓ Consulta preparada con marcadores de posición
5$sentencia = $pdo->prepare('SELECT id, nombre, hash FROM usuarios WHERE email = ?');
6$sentencia->execute([$email]);
7$usuario = $sentencia->fetch();          // array o false si no hay filas
8
9// ✓ Con marcadores con nombre, más legible cuando hay varios
10$sentencia = $pdo->prepare('INSERT INTO pedidos (cliente_id, total) VALUES (:cliente, :total)');
11$sentencia->execute(['cliente' => $clienteId, 'total' => $total]);
12$idNuevo = (int) $pdo->lastInsertId();

Lo que no se puede preparar

Los marcadores sustituyen valores, no nombres de tablas o columnas ni palabras como ASC o DESC. Si el usuario elige la columna por la que ordenar, compárala con una lista blanca: in_array($orden, ['nombre', 'precio'], true).
Curso PHP MySQL. PDO Consultas preparadas. Vídeo 53 — pildorasinformaticas

3. Leer resultados y operaciones CRUD

CRUD son las cuatro operaciones básicas sobre los datos: crear (INSERT), leer (SELECT), actualizar (UPDATE) y borrar (DELETE). Casi cualquier pantalla de gestión se reduce a ellas.

MétodoDevuelveÚsalo para
fetch()La siguiente fila o falseUn único registro: buscar por id o por email
fetchAll()Un array con todas las filasListados de tamaño razonable
fetchColumn()El valor de la primera columnaUn COUNT(*), un MAX…
foreach ($sentencia as $fila)Fila a fila, sin cargarlas todasResultados grandes
rowCount()Filas afectadasSaber si un UPDATE o DELETE ha encontrado el registro

Paginar

Nunca cargues una tabla entera para mostrar 20 filas. Pide solo la página que necesitas con LIMIT y OFFSET, y una segunda consulta COUNT(*) para saber cuántas páginas hay. Como LIMIT necesita enteros, vincula los valores con bindValue(…, PDO::PARAM_INT).

4. Transacciones: todo o nada

Hay operaciones que implican varias sentencias que deben cumplirse juntas. Al confirmar un pedido hay que crear el pedido, sus líneas y descontar el stock; si falla la última, no puede quedar un pedido a medias. Una transacción agrupa las sentencias para que se apliquen todas (commit) o ninguna (rollBack).

php
1try {
2    $pdo->beginTransaction();
3
4    $pdo->prepare('INSERT INTO pedidos (cliente_id, total) VALUES (?, ?)')->execute([$cliente, $total]);
5    $pedidoId = (int) $pdo->lastInsertId();
6
7    $linea = $pdo->prepare('INSERT INTO lineas (pedido_id, producto_id, cantidad) VALUES (?, ?, ?)');
8    $stock = $pdo->prepare('UPDATE productos SET stock = stock - ? WHERE id = ? AND stock >= ?');
9    foreach ($carrito as $item) {
10        $linea->execute([$pedidoId, $item['id'], $item['cantidad']]);
11        $stock->execute([$item['cantidad'], $item['id'], $item['cantidad']]);
12        if ($stock->rowCount() === 0) {
13            throw new RuntimeException("Sin stock de {$item['nombre']}");
14        }
15    }
16
17    $pdo->commit();
18} catch (Throwable $e) {
19    $pdo->rollBack();          // deshace el pedido y las líneas
20    throw $e;
21}

Observa el truco del UPDATE: la condición stock >= ? hace que la resta solo ocurra si hay suficiente, y rowCount() dice si ha ocurrido. Comprobar el stock en una consulta y restarlo en otra dejaría un hueco para que dos clientes compren a la vez la última unidad.

5. Organizar el acceso a datos: repositorios y ORM

Esparcir consultas SQL por los controladores lleva de vuelta al código espagueti. Lo habitual es reunir el acceso a cada tabla en una clase repositorio (o DAO), con métodos que hablan el idioma del negocio: buscar($id), masVendidos(5), guardar($producto). El resto de la aplicación no sabe si detrás hay MySQL, SQLite o una API.

src/Repositorio/ProductoRepositorio.php
1final class ProductoRepositorio
2{
3    public function __construct(private PDO $pdo) {}
4
5    public function buscar(int $id): ?Producto
6    {
7        $s = $this->pdo->prepare('SELECT * FROM productos WHERE id = ?');
8        $s->execute([$id]);
9        $fila = $s->fetch();
10        return $fila ? new Producto($fila['id'], $fila['nombre'], (float) $fila['precio'], $fila['stock']) : null;
11    }
12}

Un paso más allá están los ORM (mapeo objeto-relacional), que generan el SQL por ti a partir de tus clases: Eloquent en Laravel (Producto::where('precio', '<', 50)->get()) y Doctrine en Symfony. Ahorran mucho código repetitivo, pero conviene dominar SQL primero: cuando una página va lenta, casi siempre es por una consulta, y hay que saber leerla.

  • Usuario de base de datos con los mínimos permisos: la aplicación no necesita crear ni borrar tablas.
  • Índices en las columnas por las que buscas y unes: la diferencia puede ser de segundos a milisegundos.
  • Evita el problema N+1: no hagas una consulta por cada elemento de un listado; usa un JOIN o carga los datos relacionados de una vez.

6. Retos con una base de datos real

Cada reto crea una base de datos SQLite en memoria y la rellena con datos de ejemplo. El código PDO es exactamente el mismo que usarías con MySQL: solo cambia la línea de conexión.

Retos de PDO en el IDE

Cada programa crea su propia base de datos SQLite en memoria. Las pruebas comprueban el resultado con datos normales y con entradas maliciosas.

🐘PHPBuscar sin inyección SQLMedio

El programa lee una categoría y lista sus productos ordenados por precio («Teclado - 24,99 €»), o «Sin resultados». Funciona, pero construye la consulta pegando el texto del usuario: prueba a buscar ' OR '1'='1 y verás todos los productos. Reescríbelo con una consulta preparada.

⏳
Búsqueda normal
⏳
Intento de inyección
⏳
Test oculto #3
0/3 tests pasados
🐘PHPTransferencias con transaccionesDifícil

Cada línea es una transferencia «origen destino importe». Para cada una escribe «Cuenta inexistente», «Importe no válido» (menor o igual que 0 o no numérico), «Saldo insuficiente» o «Transferencia realizada», en ese orden de comprobación. Las dos actualizaciones de saldo deben hacerse dentro de una transacción. Al final, lista las cuentas ordenadas por id: «ES01 Ana: 400,00 €».

⏳
Varias operaciones
⏳
Encadenadas
⏳
Test oculto #3
0/3 tests pasados
🐘PHPPaginar resultados con LIMITMedio

La tabla alumnos tiene 23 filas. La entrada es «página porPágina» (por ejemplo «2 5»). Muestra los nombres de esa página ordenados por id, uno por línea, y después «Página 2 de 5». Si la página o el tamaño no son enteros positivos, o la página no existe, muestra «Página fuera de rango».

⏳
Segunda página
⏳
Última página incompleta
⏳
Fuera de rango
⏳
Test oculto #4
⏳
Test oculto #5
0/5 tests pasados

¿Has terminado este tema?

Crea una cuenta gratis para guardar qué temas has terminado, subir de nivel y ganar medallas.

Guardar mi progreso