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.
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
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
in_array($orden, ['nombre', 'precio'], true).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étodo | Devuelve | Úsalo para |
|---|---|---|
| fetch() | La siguiente fila o false | Un único registro: buscar por id o por email |
| fetchAll() | Un array con todas las filas | Listados de tamaño razonable |
| fetchColumn() | El valor de la primera columna | Un COUNT(*), un MAX… |
| foreach ($sentencia as $fila) | Fila a fila, sin cargarlas todas | Resultados grandes |
| rowCount() | Filas afectadas | Saber 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).
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.
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.
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.
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 €».
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».