Módulo 15: Bases de datos con PDO
Objetivos del módulo
- Repasar el SQL mínimo necesario:
CREATE,INSERT,SELECT,UPDATE,DELETEyJOIN - Instalar MySQL/MariaDB (XAMPP, Laragon o Docker) y un cliente gráfico
- Entender por qué usar PDO frente a
mysqli - Conectarte con un DSN y las opciones recomendadas, guardando la configuración fuera del código
- Diferenciar
query()deprepare()/execute()y usar parámetros posicionales y con nombre - Recuperar datos con
fetch(),fetchAll(),fetchColumn()yFETCH_CLASS - Construir un CRUD completo con formularios web, paginación y búsqueda segura
- Usar transacciones para operaciones que deben ser “todo o nada”
- Organizar el acceso a datos con el patrón Repository
- Conocer las subclases de PDO por driver de PHP 8.4 y SQLite como alternativa sin servidor
1. Bases de datos relacionales y SQL mínimo
Hasta ahora has guardado datos en sesiones y ficheros. Funciona para cosas pequeñas, pero en cuanto tienes miles de registros, varios usuarios escribiendo a la vez o necesitas buscar y relacionar datos, necesitas una base de datos.
📘 Concepto: Una base de datos relacional organiza la información en tablas (como hojas de cálculo) formadas por filas (registros) y columnas (campos). Las tablas se relacionan entre sí mediante claves: la clave primaria identifica cada fila de forma única y la clave foránea apunta a una fila de otra tabla. Para hablar con ella se usa SQL (Structured Query Language).
Analogía: una base de datos es un archivador. Cada cajón es una tabla (productos, categorias), cada ficha es una fila y cada casilla de la ficha es una columna. La clave primaria es el número de ficha; la clave foránea es una nota que dice “ver ficha 3 del cajón de categorías”.
Las cuatro operaciones básicas (CRUD)
| Operación | Significado | SQL |
|---|---|---|
| Create | Crear | INSERT INTO ... |
| Read | Leer | SELECT ... FROM ... |
| Update | Actualizar | UPDATE ... SET ... |
| Delete | Borrar | DELETE FROM ... |
-- Crear una tabla
CREATE TABLE categorias (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
nombre VARCHAR(100) NOT NULL UNIQUE
);
-- Insertar filas
INSERT INTO categorias (nombre) VALUES ('Periféricos'), ('Monitores');
-- Leer
SELECT id, nombre FROM categorias ORDER BY nombre;
-- Filtrar
SELECT * FROM productos WHERE precio < 50 AND stock > 0;
-- Actualizar (¡con WHERE!)
UPDATE productos SET precio = 39.90 WHERE id = 3;
-- Borrar (¡con WHERE!)
DELETE FROM productos WHERE id = 3;
-- Relacionar dos tablas
SELECT p.nombre, p.precio, c.nombre AS categoria
FROM productos p
JOIN categorias c ON c.id = p.categoria_id;
-- Contar y agrupar
SELECT c.nombre, COUNT(*) AS total
FROM productos p
JOIN categorias c ON c.id = p.categoria_id
GROUP BY c.nombre;
⚠️ Importante: Un
UPDATEoDELETEsinWHEREafecta a todas las filas de la tabla. Antes de ejecutarlos, prueba el mismoWHEREcon unSELECTpara ver qué filas se verán afectadas.
2. Instalar MySQL o MariaDB
MySQL y MariaDB son los gestores más usados con PHP. MariaDB nació como una bifurcación de MySQL y es compatible con él en casi todo lo que verás en este curso.
| Opción | Ventajas | Ideal para |
|---|---|---|
| XAMPP | Todo en uno (Apache + MariaDB + PHP + phpMyAdmin) | Empezar en Windows sin complicaciones |
| Laragon | Ligero, rápido, cambia de versión de PHP con un clic, incluye HeidiSQL | Desarrollo diario en Windows |
| Docker | El mismo entorno en cualquier máquina, aislado | Proyectos en equipo y entornos reproducibles |
Con XAMPP o Laragon
- Abre el panel de control y arranca MySQL (en XAMPP) o pulsa Start All (en Laragon).
- Con XAMPP abre
http://localhost/phpmyadmin. Con Laragon pulsa Database para abrir HeidiSQL. - El usuario por defecto es
rootsin contraseña. Para desarrollo local vale; en cualquier otro sitio es inaceptable.
Con Docker
Crea un fichero compose.yaml en la carpeta de tu proyecto:
services:
db:
image: mariadb:11
environment:
MARIADB_ROOT_PASSWORD: root
MARIADB_DATABASE: tienda
MARIADB_USER: tienda
MARIADB_PASSWORD: secreto
ports:
- "3306:3306"
volumes:
- datos-db:/var/lib/mysql
phpmyadmin:
image: phpmyadmin
environment:
PMA_HOST: db
ports:
- "8080:80"
depends_on:
- db
volumes:
datos-db:
docker compose up -d
# phpMyAdmin en http://localhost:8080 (usuario tienda / secreto)
Clientes gráficos
| Cliente | Tipo | Comentario |
|---|---|---|
| phpMyAdmin | Web | Viene con XAMPP; muy extendido en hostings |
| HeidiSQL | Escritorio (Windows) | Rápido y sencillo; incluido en Laragon |
| DBeaver | Escritorio (multiplataforma) | Soporta casi cualquier base de datos |
| Extensiones de VS Code | Editor | Útiles para consultas rápidas sin salir del editor |
💡 Consejo: Crea un usuario específico para cada aplicación con permisos solo sobre su base de datos, en lugar de conectarte siempre como
root. Es una práctica de seguridad básica que verás reforzada en el módulo siguiente.
3. Script SQL de ejemplo: una tienda
Usaremos esta base de datos durante todo el módulo. Guárdala como tienda.sql y ejecútala en phpMyAdmin (pestaña SQL o Importar) o en HeidiSQL.
-- tienda.sql
CREATE DATABASE IF NOT EXISTS tienda
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE tienda;
CREATE TABLE categorias (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
nombre VARCHAR(100) NOT NULL UNIQUE
) ENGINE=InnoDB;
CREATE TABLE productos (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
categoria_id INT UNSIGNED NOT NULL,
nombre VARCHAR(150) NOT NULL,
descripcion TEXT NULL,
precio DECIMAL(10,2) NOT NULL CHECK (precio >= 0),
stock INT UNSIGNED NOT NULL DEFAULT 0,
creado_en DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_productos_categoria
FOREIGN KEY (categoria_id) REFERENCES categorias(id)
ON DELETE RESTRICT
) ENGINE=InnoDB;
CREATE TABLE pedidos (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
cliente VARCHAR(150) NOT NULL,
total DECIMAL(10,2) NOT NULL DEFAULT 0,
creado_en DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
CREATE TABLE lineas_pedido (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
pedido_id INT UNSIGNED NOT NULL,
producto_id INT UNSIGNED NOT NULL,
cantidad INT UNSIGNED NOT NULL,
precio DECIMAL(10,2) NOT NULL,
FOREIGN KEY (pedido_id) REFERENCES pedidos(id) ON DELETE CASCADE,
FOREIGN KEY (producto_id) REFERENCES productos(id)
) ENGINE=InnoDB;
INSERT INTO categorias (nombre) VALUES ('Periféricos'), ('Monitores'), ('Almacenamiento');
INSERT INTO productos (categoria_id, nombre, descripcion, precio, stock) VALUES
(1, 'Teclado mecánico', 'Switches marrones, retroiluminado', 79.90, 15),
(1, 'Ratón inalámbrico', 'Sensor de 16000 DPI', 34.50, 40),
(1, 'Alfombrilla XXL', '90 x 40 cm', 19.99, 60),
(2, 'Monitor 27" QHD', 'IPS, 165 Hz', 289.00, 8),
(2, 'Monitor 24" Full HD', 'IPS, 75 Hz', 129.00, 12),
(3, 'SSD NVMe 1 TB', 'PCIe 4.0', 74.95, 25),
(3, 'Disco externo 2 TB', 'USB-C', 69.00, 18),
(3, 'Pendrive 128 GB', 'USB 3.2', 12.49, 100);
-- Usuario propio para la aplicación (no uses root)
CREATE USER IF NOT EXISTS 'tienda'@'localhost' IDENTIFIED BY 'secreto';
GRANT SELECT, INSERT, UPDATE, DELETE ON tienda.* TO 'tienda'@'localhost';
📘 Concepto:
utf8mb4es el juego de caracteres que admite todo Unicode, incluidos emojis y caracteres de cualquier idioma. El antiguoutf8de MySQL solo admitía 3 bytes por carácter y no sirve para todo. Usa siempreutf8mb4.
4. PDO frente a mysqli
PHP ofrece dos extensiones para trabajar con MySQL:
| Característica | PDO | mysqli |
|---|---|---|
| Bases de datos soportadas | MySQL, MariaDB, PostgreSQL, SQLite, SQL Server, Oracle… | Solo MySQL/MariaDB |
| API | Orientada a objetos | Orientada a objetos y procedimental |
Parámetros con nombre (:email) | Sí | No (solo ?) |
| Excepciones | Sí (por defecto desde PHP 8.0) | Sí (por defecto desde PHP 8.1) |
Hidratar objetos (FETCH_CLASS) | Sí | Limitado |
| Usado por frameworks (Doctrine, Laravel) | Sí | No |
💡 Consejo: Usa PDO. Aprendes una sola API que te sirve para cualquier base de datos y es la que encontrarás en frameworks y librerías.
mysqlisolo tiene sentido si necesitas una característica muy específica de MySQL.
Comprueba que tienes el driver activo:
php -m | grep -i pdo
# PDO
# pdo_mysql
# pdo_sqlite
En Windows (PowerShell) usa php -m | Select-String pdo. Si falta pdo_mysql, descomenta extension=pdo_mysql en php.ini.
5. Conectar con PDO
El DSN
📘 Concepto: El DSN (Data Source Name) es una cadena que indica a PDO qué driver usar y dónde está la base de datos. Su formato es
driver:clave=valor;clave=valor.
mysql:host=localhost;port=3306;dbname=tienda;charset=utf8mb4
sqlite:/ruta/a/base.sqlite
pgsql:host=localhost;port=5432;dbname=tienda
Conexión con las opciones recomendadas
<?php
declare(strict_types=1);
$dsn = 'mysql:host=localhost;port=3306;dbname=tienda;charset=utf8mb4';
$opciones = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // Errores como excepciones
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, // Filas como arrays asociativos
PDO::ATTR_EMULATE_PREPARES => false, // Consultas preparadas reales
];
try {
$pdo = new PDO($dsn, 'tienda', 'secreto', $opciones);
echo "Conectado a MySQL ", $pdo->getAttribute(PDO::ATTR_SERVER_VERSION), PHP_EOL;
} catch (PDOException $e) {
error_log('Error de conexión: ' . $e->getMessage());
exit('No se pudo conectar con la base de datos.');
}
| Opción | Qué hace | Por qué |
|---|---|---|
ERRMODE_EXCEPTION | Lanza PDOException ante cualquier error SQL | Es el valor por defecto desde PHP 8.0, pero ponlo explícito: los errores nunca pasan desapercibidos |
FETCH_ASSOC | Las filas se devuelven como ['id' => 1, 'nombre' => '...'] | Evita el doble índice (numérico y por nombre) del modo por defecto |
EMULATE_PREPARES => false | El servidor prepara la consulta de verdad | Más seguro y los enteros llegan como int |
charset=utf8mb4 (en el DSN) | Codificación de la conexión | Evita problemas con tildes, eñes y emojis |
⚠️ Importante: Nunca muestres
$e->getMessage()de unaPDOExceptional usuario: puede contener el nombre del host, del usuario y de la base de datos. Regístralo en el log y muestra un mensaje genérico.
Configuración en un fichero aparte
Las credenciales no deben estar repartidas por el código ni subirse a Git. Una opción sencilla es un fichero de configuración que devuelve un array:
<?php
// config/database.php (añádelo a .gitignore)
return [
'host' => 'localhost',
'port' => 3306,
'dbname' => 'tienda',
'user' => 'tienda',
'password' => 'secreto',
];
Una opción más profesional es un fichero .env con variables de entorno (lo verás con la librería vlucas/phpdotenv en el módulo de MVC):
# .env (nunca se sube a Git; se sube un .env.example sin valores reales)
DB_HOST=localhost
DB_PORT=3306
DB_NAME=tienda
DB_USER=tienda
DB_PASS=secreto
Una función de conexión reutilizable
<?php
declare(strict_types=1);
// src/conexion.php
function conexion(): PDO
{
static $pdo = null; // Se conserva entre llamadas: una sola conexión por petición
if ($pdo === null) {
$config = require __DIR__ . '/../config/database.php';
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4',
$config['host'],
$config['port'],
$config['dbname'],
);
$pdo = new PDO($dsn, $config['user'], $config['password'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
}
return $pdo;
}
💡 Consejo: La variable
staticdentro de la función hace que la conexión se cree una sola vez por petición, aunque llames aconexion()desde veinte sitios. Conectar es una operación relativamente cara.
6. query() frente a prepare() y execute()
query(): consultas sin datos externos
query() ejecuta directamente una consulta SQL fija, sin datos que vengan del usuario:
<?php
declare(strict_types=1);
require __DIR__ . '/src/conexion.php';
$stmt = conexion()->query('SELECT id, nombre FROM categorias ORDER BY nombre');
foreach ($stmt as $fila) {
echo "{$fila['id']}: {$fila['nombre']}", PHP_EOL;
}
// 3: Almacenamiento
// 2: Monitores
// 1: Periféricos
prepare() y execute(): consultas con datos
Cuando la consulta incluye cualquier dato variable (de un formulario, de la URL, de una cookie, incluso de tu propia base de datos), usa consultas preparadas.
📘 Concepto: En una consulta preparada, la estructura SQL y los datos viajan por separado. Primero envías la plantilla con huecos (
?o:nombre) y luego los valores. El servidor nunca interpreta los valores como SQL, por lo que es imposible que un dato malicioso cambie la consulta. Es la defensa principal contra la inyección SQL.
Analogía: es como un formulario en papel con casillas. Quien lo rellena solo puede escribir dentro de las casillas; no puede añadir nuevas preguntas ni tachar las existentes.
<?php
// ❌ NUNCA: concatenar datos del usuario en el SQL
$id = $_GET['id'];
$stmt = $pdo->query("SELECT * FROM productos WHERE id = $id");
// Si alguien visita ?id=1 OR 1=1 obtiene todos los productos (o algo peor)
// ✅ SIEMPRE: consulta preparada
$stmt = $pdo->prepare('SELECT * FROM productos WHERE id = ?');
$stmt->execute([$_GET['id']]);
$producto = $stmt->fetch();
Parámetros posicionales (?)
<?php
$stmt = $pdo->prepare('SELECT nombre, precio FROM productos WHERE categoria_id = ? AND precio < ?');
$stmt->execute([1, 50]);
print_r($stmt->fetchAll());
Parámetros con nombre (:nombre)
Más legibles cuando hay muchos parámetros, y el orden da igual:
<?php
$stmt = $pdo->prepare(
'INSERT INTO productos (categoria_id, nombre, precio, stock)
VALUES (:categoria, :nombre, :precio, :stock)'
);
$stmt->execute([
'nombre' => 'Webcam 1080p',
'precio' => 45.00,
'stock' => 20,
'categoria' => 1,
]);
⚠️ Importante: No puedes mezclar
?y:nombreen la misma consulta. Y los parámetros sirven solo para valores, no para nombres de tablas, columnas o palabras clave comoASC/DESC. Para eso usa una lista blanca (lo verás en la sección de paginación).
bindValue() y los tipos
Con execute([...]) todos los valores se envían como cadenas. Normalmente da igual, pero hay casos (como LIMIT) donde necesitas indicar el tipo:
<?php
$stmt = $pdo->prepare('SELECT * FROM productos WHERE stock > :minimo LIMIT :limite');
$stmt->bindValue(':minimo', 10, PDO::PARAM_INT);
$stmt->bindValue(':limite', 5, PDO::PARAM_INT);
$stmt->execute();
| Constante | Tipo |
|---|---|
PDO::PARAM_STR | Cadena (por defecto) |
PDO::PARAM_INT | Entero |
PDO::PARAM_BOOL | Booleano |
PDO::PARAM_NULL | NULL de SQL |
PDO::PARAM_LOB | Datos binarios grandes |
💡 Consejo:
bindParam()es parecido pero enlaza una variable por referencia: se lee su valor en el momento delexecute(). Es útil para ejecutar la misma sentencia muchas veces en un bucle. En el resto de casos,bindValue()oexecute([...])son más claros.
7. Recuperar resultados
| Método | Devuelve | Cuándo usarlo |
|---|---|---|
fetch() | La siguiente fila o false si no hay más | Un único registro o recorrer de uno en uno |
fetchAll() | Array con todas las filas (vacío si no hay) | Listados de tamaño razonable |
fetchColumn() | El valor de una columna de la siguiente fila | COUNT(*), MAX(), un solo dato |
foreach ($stmt as $fila) | Itera las filas | Recorridos grandes sin cargarlos todos en memoria |
<?php
declare(strict_types=1);
require __DIR__ . '/src/conexion.php';
$pdo = conexion();
// Una fila
$stmt = $pdo->prepare('SELECT * FROM productos WHERE id = ?');
$stmt->execute([4]);
$producto = $stmt->fetch();
if ($producto === false) {
echo "No encontrado", PHP_EOL;
} else {
echo "{$producto['nombre']}: {$producto['precio']} €", PHP_EOL; // Monitor 27" QHD: 289.00 €
}
// Todas las filas
$baratos = $pdo->query('SELECT nombre, precio FROM productos WHERE precio < 30')->fetchAll();
print_r($baratos);
// Un único valor
$total = (int) $pdo->query('SELECT COUNT(*) FROM productos')->fetchColumn();
echo "Hay $total productos", PHP_EOL; // Hay 8 productos
// Pares clave => valor (muy útil para <select>)
$categorias = $pdo->query('SELECT id, nombre FROM categorias')->fetchAll(PDO::FETCH_KEY_PAIR);
// [1 => 'Periféricos', 2 => 'Monitores', 3 => 'Almacenamiento']
// Una columna como lista
$nombres = $pdo->query('SELECT nombre FROM productos')->fetchAll(PDO::FETCH_COLUMN);
⚠️ Importante: Las columnas
DECIMALllegan como cadena ("289.00"), porque PHP no tiene un tipo decimal exacto y convertirlas afloatpodría perder precisión. Conviértelas tú si lo necesitas, o trabaja con céntimos enteros para dinero.
FETCH_CLASS: filas convertidas en objetos
<?php
declare(strict_types=1);
final class Producto
{
public int $id;
public int $categoria_id;
public string $nombre;
public ?string $descripcion;
public string $precio;
public int $stock;
public string $creado_en;
public function precioConIva(): float
{
return round((float) $this->precio * 1.21, 2);
}
}
$stmt = $pdo->query('SELECT * FROM productos ORDER BY precio DESC LIMIT 3');
$productos = $stmt->fetchAll(PDO::FETCH_CLASS, Producto::class);
foreach ($productos as $p) {
echo "{$p->nombre}: {$p->precioConIva()} € con IVA", PHP_EOL;
}
// Monitor 27" QHD: 349.69 € con IVA
// Monitor 24" Full HD: 156.09 € con IVA
// Teclado mecánico: 96.68 € con IVA
📘 Concepto: Con
FETCH_CLASS, PDO crea el objeto y asigna cada columna a la propiedad del mismo nombre antes de llamar al constructor. Por eso no encaja bien con propiedadesreadonlypromovidas en el constructor.
Para objetos inmutables y con nombres en camelCase, la alternativa más limpia es un constructor estático que reciba la fila:
<?php
declare(strict_types=1);
final readonly class ProductoDto
{
public function __construct(
public int $id,
public string $nombre,
public float $precio,
public int $stock,
) {}
public static function desdeFila(array $fila): self
{
return new self(
id: (int) $fila['id'],
nombre: $fila['nombre'],
precio: (float) $fila['precio'],
stock: (int) $fila['stock'],
);
}
}
$filas = $pdo->query('SELECT id, nombre, precio, stock FROM productos')->fetchAll();
$productos = array_map(ProductoDto::desdeFila(...), $filas);
8. lastInsertId() y rowCount()
<?php
declare(strict_types=1);
// Tras un INSERT: id autoincremental generado
$stmt = $pdo->prepare('INSERT INTO categorias (nombre) VALUES (?)');
$stmt->execute(['Audio']);
$nuevoId = (int) $pdo->lastInsertId();
echo "Categoría creada con id $nuevoId", PHP_EOL;
// Tras un UPDATE o DELETE: filas afectadas
$stmt = $pdo->prepare('UPDATE productos SET precio = precio * 0.9 WHERE categoria_id = ?');
$stmt->execute([1]);
echo "Se han rebajado {$stmt->rowCount()} productos", PHP_EOL;
$stmt = $pdo->prepare('DELETE FROM productos WHERE id = ?');
$stmt->execute([999]);
if ($stmt->rowCount() === 0) {
echo "No había ningún producto con ese id", PHP_EOL;
}
⚠️ Importante: En MySQL,
rowCount()tras unUPDATEcuenta las filas realmente modificadas: si actualizas una fila con los mismos valores que ya tenía, devuelve 0. Y no te fíes derowCount()tras unSELECT: no todos los drivers lo soportan. Para contar, usaCOUNT(*).
9. CRUD completo con formularios web
Vamos a construir una pequeña aplicación para gestionar los productos de la tienda. Usaremos ficheros PHP sencillos (en el módulo 19 los reorganizaremos con MVC).
tienda/
├── config/
│ └── database.php
├── src/
│ ├── conexion.php
│ └── helpers.php
└── public/
├── index.php ← listado, búsqueda y paginación
├── crear.php ← formulario de alta
├── editar.php ← formulario de edición
└── borrar.php ← borrado (solo POST)
Arranca el servidor integrado apuntando a public/:
php -S localhost:8000 -t public
Funciones auxiliares
<?php
declare(strict_types=1);
// src/helpers.php
/** Escapa texto para mostrarlo en HTML (evita XSS, módulo 16) */
function e(?string $texto): string
{
return htmlspecialchars($texto ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
/** Redirige y termina el script */
function redirigir(string $url): never
{
header("Location: $url", true, 303);
exit;
}
/** Valida los datos del formulario de producto y devuelve [datos, errores] */
function validarProducto(array $entrada): array
{
$datos = [
'nombre' => mb_trim($entrada['nombre'] ?? ''),
'descripcion' => mb_trim($entrada['descripcion'] ?? ''),
'precio' => $entrada['precio'] ?? '',
'stock' => $entrada['stock'] ?? '',
'categoria_id' => $entrada['categoria_id'] ?? '',
];
$errores = [];
if ($datos['nombre'] === '' || mb_strlen($datos['nombre']) > 150) {
$errores['nombre'] = 'El nombre es obligatorio (máx. 150 caracteres).';
}
if (filter_var($datos['precio'], FILTER_VALIDATE_FLOAT) === false || (float) $datos['precio'] < 0) {
$errores['precio'] = 'El precio debe ser un número mayor o igual que 0.';
}
if (filter_var($datos['stock'], FILTER_VALIDATE_INT, ['options' => ['min_range' => 0]]) === false) {
$errores['stock'] = 'El stock debe ser un entero mayor o igual que 0.';
}
if (filter_var($datos['categoria_id'], FILTER_VALIDATE_INT) === false) {
$errores['categoria_id'] = 'Elige una categoría.';
}
return [$datos, $errores];
}
Listar (index.php)
<?php
declare(strict_types=1);
require __DIR__ . '/../src/conexion.php';
require __DIR__ . '/../src/helpers.php';
$pdo = conexion();
$productos = $pdo->query(
'SELECT p.id, p.nombre, p.precio, p.stock, c.nombre AS categoria
FROM productos p
JOIN categorias c ON c.id = p.categoria_id
ORDER BY p.nombre'
)->fetchAll();
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Productos</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css">
</head>
<body class="container py-4">
<h1>Productos</h1>
<a href="crear.php" class="btn btn-primary mb-3">Nuevo producto</a>
<table class="table table-striped align-middle">
<thead>
<tr><th>Nombre</th><th>Categoría</th><th class="text-end">Precio</th><th class="text-end">Stock</th><th></th></tr>
</thead>
<tbody>
<?php foreach ($productos as $p): ?>
<tr>
<td><?= e($p['nombre']) ?></td>
<td><?= e($p['categoria']) ?></td>
<td class="text-end"><?= number_format((float) $p['precio'], 2, ',', '.') ?> €</td>
<td class="text-end"><?= $p['stock'] ?></td>
<td class="text-end">
<a href="editar.php?id=<?= $p['id'] ?>" class="btn btn-sm btn-outline-secondary">Editar</a>
<form action="borrar.php" method="post" class="d-inline"
onsubmit="return confirm('¿Seguro que quieres borrarlo?')">
<input type="hidden" name="id" value="<?= $p['id'] ?>">
<button class="btn btn-sm btn-outline-danger">Borrar</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</body>
</html>
Crear (crear.php)
<?php
declare(strict_types=1);
require __DIR__ . '/../src/conexion.php';
require __DIR__ . '/../src/helpers.php';
$pdo = conexion();
$categorias = $pdo->query('SELECT id, nombre FROM categorias ORDER BY nombre')->fetchAll(PDO::FETCH_KEY_PAIR);
$datos = ['nombre' => '', 'descripcion' => '', 'precio' => '', 'stock' => '0', 'categoria_id' => ''];
$errores = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
[$datos, $errores] = validarProducto($_POST);
if (!isset($categorias[(int) $datos['categoria_id']])) {
$errores['categoria_id'] = 'La categoría no existe.';
}
if ($errores === []) {
$stmt = $pdo->prepare(
'INSERT INTO productos (categoria_id, nombre, descripcion, precio, stock)
VALUES (:categoria_id, :nombre, :descripcion, :precio, :stock)'
);
$stmt->execute([
'categoria_id' => (int) $datos['categoria_id'],
'nombre' => $datos['nombre'],
'descripcion' => $datos['descripcion'] !== '' ? $datos['descripcion'] : null,
'precio' => $datos['precio'],
'stock' => (int) $datos['stock'],
]);
// Patrón PRG (Post/Redirect/Get): evita reenvíos al recargar
redirigir('index.php?creado=' . $pdo->lastInsertId());
}
}
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Nuevo producto</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css">
</head>
<body class="container py-4">
<h1>Nuevo producto</h1>
<form method="post" novalidate>
<div class="mb-3">
<label class="form-label" for="nombre">Nombre</label>
<input class="form-control <?= isset($errores['nombre']) ? 'is-invalid' : '' ?>"
id="nombre" name="nombre" value="<?= e($datos['nombre']) ?>">
<div class="invalid-feedback"><?= e($errores['nombre'] ?? '') ?></div>
</div>
<div class="mb-3">
<label class="form-label" for="descripcion">Descripción</label>
<textarea class="form-control" id="descripcion" name="descripcion"><?= e($datos['descripcion']) ?></textarea>
</div>
<div class="row">
<div class="col mb-3">
<label class="form-label" for="precio">Precio (€)</label>
<input class="form-control <?= isset($errores['precio']) ? 'is-invalid' : '' ?>"
id="precio" name="precio" inputmode="decimal" value="<?= e($datos['precio']) ?>">
<div class="invalid-feedback"><?= e($errores['precio'] ?? '') ?></div>
</div>
<div class="col mb-3">
<label class="form-label" for="stock">Stock</label>
<input class="form-control <?= isset($errores['stock']) ? 'is-invalid' : '' ?>"
id="stock" name="stock" type="number" min="0" value="<?= e($datos['stock']) ?>">
<div class="invalid-feedback"><?= e($errores['stock'] ?? '') ?></div>
</div>
</div>
<div class="mb-3">
<label class="form-label" for="categoria_id">Categoría</label>
<select class="form-select <?= isset($errores['categoria_id']) ? 'is-invalid' : '' ?>"
id="categoria_id" name="categoria_id">
<option value="">-- Elige --</option>
<?php foreach ($categorias as $id => $nombre): ?>
<option value="<?= $id ?>" <?= (string) $id === (string) $datos['categoria_id'] ? 'selected' : '' ?>>
<?= e($nombre) ?>
</option>
<?php endforeach; ?>
</select>
<div class="invalid-feedback"><?= e($errores['categoria_id'] ?? '') ?></div>
</div>
<button class="btn btn-primary">Guardar</button>
<a href="index.php" class="btn btn-link">Cancelar</a>
</form>
</body>
</html>
Editar (editar.php)
La edición es casi igual que el alta: primero se cargan los datos actuales y, al enviar, se ejecuta un UPDATE.
<?php
declare(strict_types=1);
require __DIR__ . '/../src/conexion.php';
require __DIR__ . '/../src/helpers.php';
$pdo = conexion();
$id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT);
$stmt = $pdo->prepare('SELECT * FROM productos WHERE id = ?');
$stmt->execute([$id]);
$datos = $stmt->fetch();
if (!$id || $datos === false) {
http_response_code(404);
exit('Producto no encontrado');
}
$categorias = $pdo->query('SELECT id, nombre FROM categorias ORDER BY nombre')->fetchAll(PDO::FETCH_KEY_PAIR);
$errores = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
[$datos, $errores] = validarProducto($_POST);
if ($errores === []) {
$stmt = $pdo->prepare(
'UPDATE productos
SET categoria_id = :categoria_id, nombre = :nombre, descripcion = :descripcion,
precio = :precio, stock = :stock
WHERE id = :id'
);
$stmt->execute([
'categoria_id' => (int) $datos['categoria_id'],
'nombre' => $datos['nombre'],
'descripcion' => $datos['descripcion'] !== '' ? $datos['descripcion'] : null,
'precio' => $datos['precio'],
'stock' => (int) $datos['stock'],
'id' => $id,
]);
redirigir('index.php?editado=' . $id);
}
}
// A continuación va el mismo HTML que en crear.php, cambiando el título
// por "Editar producto" y el action del formulario por editar.php?id=...
⚠️ Importante: Ojo con escribir
?>dentro de un comentario//de PHP: la etiqueta de cierre termina el bloque PHP aunque esté en un comentario de una línea. Es un error curioso que te puede hacer perder un buen rato.
💡 Consejo: El formulario de alta y el de edición son idénticos. En un proyecto real los extraerías a un fichero
_formulario.phpque incluyes desde ambos, para no duplicar HTML.
Borrar (borrar.php)
<?php
declare(strict_types=1);
require __DIR__ . '/../src/conexion.php';
require __DIR__ . '/../src/helpers.php';
// Borrar SOLO por POST: un enlace GET podría dispararse por accidente
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
header('Allow: POST');
exit('Método no permitido');
}
$id = filter_input(INPUT_POST, 'id', FILTER_VALIDATE_INT);
if ($id) {
try {
$stmt = conexion()->prepare('DELETE FROM productos WHERE id = ?');
$stmt->execute([$id]);
} catch (PDOException $e) {
// 23000: violación de integridad (p. ej. el producto está en algún pedido)
if ($e->getCode() === '23000') {
redirigir('index.php?error=en-uso');
}
throw $e;
}
}
redirigir('index.php?borrado=1');
⚠️ Importante: Las operaciones que modifican datos (crear, editar, borrar) deben usar POST, nunca enlaces GET. Además, a este CRUD le falta protección CSRF, que añadiremos en el módulo 16. No lo publiques en Internet tal cual.
10. Paginación con LIMIT y OFFSET
Si tienes 10 000 productos no los vas a mostrar todos en una página. Se muestran, por ejemplo, de 10 en 10.
📘 Concepto:
LIMIT n OFFSET mdevuelve como máximonfilas saltándose lasmprimeras. Para la páginap(empezando en 1) connelementos por página:OFFSET = (p - 1) * n.
<?php
declare(strict_types=1);
$porPagina = 5;
$pagina = max(1, (int) ($_GET['pagina'] ?? 1));
$total = (int) $pdo->query('SELECT COUNT(*) FROM productos')->fetchColumn();
$totalPaginas = max(1, (int) ceil($total / $porPagina));
$pagina = min($pagina, $totalPaginas);
$stmt = $pdo->prepare('SELECT id, nombre, precio FROM productos ORDER BY nombre LIMIT :limite OFFSET :desplazamiento');
$stmt->bindValue(':limite', $porPagina, PDO::PARAM_INT);
$stmt->bindValue(':desplazamiento', ($pagina - 1) * $porPagina, PDO::PARAM_INT);
$stmt->execute();
$productos = $stmt->fetchAll();
Los enlaces de paginación con Bootstrap:
<nav>
<ul class="pagination">
<?php for ($i = 1; $i <= $totalPaginas; $i++): ?>
<li class="page-item <?= $i === $pagina ? 'active' : '' ?>">
<a class="page-link" href="?<?= e(http_build_query(['pagina' => $i, 'q' => $q ?? ''])) ?>"><?= $i ?></a>
</li>
<?php endfor; ?>
</ul>
</nav>
Ordenación segura con lista blanca
Los nombres de columna no pueden ir como parámetros. Si el usuario elige la ordenación, compara su elección con una lista de valores permitidos:
<?php
$columnas = ['nombre' => 'p.nombre', 'precio' => 'p.precio', 'stock' => 'p.stock'];
$orden = $columnas[$_GET['orden'] ?? ''] ?? 'p.nombre';
$direccion = ($_GET['dir'] ?? '') === 'desc' ? 'DESC' : 'ASC';
$sql = "SELECT p.* FROM productos p ORDER BY $orden $direccion LIMIT :limite OFFSET :desplazamiento";
// $orden y $direccion solo pueden tomar valores que TÚ has escrito
💡 Consejo:
OFFSETse vuelve lento con desplazamientos muy grandes (la base de datos tiene que recorrer y descartar las filas). Para listados enormes existe la paginación por cursor (WHERE id > :ultimoId LIMIT 10), pero para un CRUD normalLIMIT/OFFSETes perfecto.
11. Búsqueda segura con LIKE
<?php
declare(strict_types=1);
$q = mb_trim($_GET['q'] ?? '');
if ($q !== '') {
// Escapamos los comodines de LIKE para que se busquen literalmente
$patron = '%' . addcslashes($q, '%_\\') . '%';
$stmt = $pdo->prepare(
'SELECT id, nombre, precio FROM productos
WHERE nombre LIKE :q OR descripcion LIKE :q2
ORDER BY nombre'
);
$stmt->execute(['q' => $patron, 'q2' => $patron]);
$productos = $stmt->fetchAll();
}
Formulario de búsqueda:
<form method="get" class="d-flex gap-2 mb-3">
<input class="form-control" type="search" name="q" placeholder="Buscar productos..."
value="<?= e($q) ?>">
<button class="btn btn-outline-primary">Buscar</button>
</form>
⚠️ Importante: En
LIKE,%significa “cualquier cadena” y_“un carácter cualquiera”. Si no los escapas, un usuario que busque%obtendrá todos los registros. No es una inyección SQL (el parámetro sigue siendo seguro), pero sí un comportamiento inesperado y, con tablas grandes, un problema de rendimiento.
💡 Consejo: Con
EMULATE_PREPARES = falseno puedes repetir el mismo parámetro con nombre (:qdos veces) en una consulta: PDO lanza un error de número de parámetros. Por eso el ejemplo usa:qy:q2con el mismo valor.
12. Transacciones
📘 Concepto: Una transacción agrupa varias operaciones para que se ejecuten todas o ninguna. Si algo falla a mitad, se deshace todo (rollback) y la base de datos queda como estaba. Es la “A” de ACID (Atomicity).
Analogía: una transferencia bancaria. Quitar dinero de una cuenta y ponerlo en otra son dos pasos; si el segundo falla, el primero debe deshacerse o el dinero “desaparece”.
💻 Ejemplo: Crear un pedido con sus líneas y descontar el stock.
<?php
declare(strict_types=1);
final class StockInsuficiente extends RuntimeException {}
/**
* @param array<int, int> $lineas producto_id => cantidad
*/
function crearPedido(PDO $pdo, string $cliente, array $lineas): int
{
$pdo->beginTransaction();
try {
$pdo->prepare('INSERT INTO pedidos (cliente) VALUES (?)')->execute([$cliente]);
$pedidoId = (int) $pdo->lastInsertId();
// FOR UPDATE bloquea la fila hasta el final de la transacción
$leer = $pdo->prepare('SELECT nombre, precio, stock FROM productos WHERE id = ? FOR UPDATE');
$insertarLinea = $pdo->prepare(
'INSERT INTO lineas_pedido (pedido_id, producto_id, cantidad, precio) VALUES (?, ?, ?, ?)'
);
$descontar = $pdo->prepare('UPDATE productos SET stock = stock - ? WHERE id = ?');
$total = 0.0;
foreach ($lineas as $productoId => $cantidad) {
$leer->execute([$productoId]);
$producto = $leer->fetch();
if ($producto === false) {
throw new InvalidArgumentException("Producto $productoId inexistente");
}
if ($producto['stock'] < $cantidad) {
throw new StockInsuficiente("No hay stock suficiente de {$producto['nombre']}");
}
$insertarLinea->execute([$pedidoId, $productoId, $cantidad, $producto['precio']]);
$descontar->execute([$cantidad, $productoId]);
$total += (float) $producto['precio'] * $cantidad;
}
$pdo->prepare('UPDATE pedidos SET total = ? WHERE id = ?')->execute([$total, $pedidoId]);
$pdo->commit(); // Todo bien: se confirman todos los cambios
return $pedidoId;
} catch (Throwable $e) {
$pdo->rollBack(); // Algo falló: se deshace TODO
throw $e; // Y se informa a quien llamó
}
}
try {
$id = crearPedido(conexion(), 'Ana García', [1 => 2, 6 => 1]);
echo "Pedido $id creado", PHP_EOL;
} catch (StockInsuficiente $e) {
echo $e->getMessage(), PHP_EOL;
}
| Método | Qué hace |
|---|---|
beginTransaction() | Inicia la transacción (desactiva el autocommit) |
commit() | Confirma los cambios de forma permanente |
rollBack() | Deshace todos los cambios desde beginTransaction() |
inTransaction() | Devuelve true si hay una transacción en curso |
⚠️ Importante: Las transacciones solo funcionan con motores que las soporten, como InnoDB (el predeterminado en MySQL y MariaDB modernos). Y algunas sentencias, como
CREATE TABLEoALTER TABLE, provocan un commit implícito en MySQL: no las mezcles dentro de una transacción.
13. El patrón Repository
Si repartes SQL por todos los ficheros, acabarás con la misma consulta copiada en diez sitios. El patrón Repository concentra todo el acceso a datos de una entidad en una clase.
📘 Concepto: Un repositorio es una clase que actúa como una “colección” de objetos de un tipo:
buscarPorId(),buscarTodos(),guardar(),borrar(). El resto de la aplicación habla con el repositorio y no sabe ni le importa que por debajo haya MySQL, SQLite o una API.
<?php
declare(strict_types=1);
namespace App\Repository;
use PDO;
final class ProductoRepository
{
public function __construct(private PDO $pdo) {}
public function buscarPorId(int $id): ?array
{
$stmt = $this->pdo->prepare('SELECT * FROM productos WHERE id = ?');
$stmt->execute([$id]);
return $stmt->fetch() ?: null;
}
/** @return list<array> */
public function buscar(string $texto = '', int $pagina = 1, int $porPagina = 10): array
{
$stmt = $this->pdo->prepare(
'SELECT * FROM productos WHERE nombre LIKE :q ORDER BY nombre LIMIT :limite OFFSET :desp'
);
$stmt->bindValue(':q', '%' . addcslashes($texto, '%_\\') . '%');
$stmt->bindValue(':limite', $porPagina, PDO::PARAM_INT);
$stmt->bindValue(':desp', ($pagina - 1) * $porPagina, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
}
public function contar(string $texto = ''): int
{
$stmt = $this->pdo->prepare('SELECT COUNT(*) FROM productos WHERE nombre LIKE ?');
$stmt->execute(['%' . addcslashes($texto, '%_\\') . '%']);
return (int) $stmt->fetchColumn();
}
public function crear(array $datos): int
{
$stmt = $this->pdo->prepare(
'INSERT INTO productos (categoria_id, nombre, descripcion, precio, stock)
VALUES (:categoria_id, :nombre, :descripcion, :precio, :stock)'
);
$stmt->execute($datos);
return (int) $this->pdo->lastInsertId();
}
public function actualizar(int $id, array $datos): bool
{
$stmt = $this->pdo->prepare(
'UPDATE productos SET categoria_id = :categoria_id, nombre = :nombre,
descripcion = :descripcion, precio = :precio, stock = :stock WHERE id = :id'
);
$stmt->execute([...$datos, 'id' => $id]);
return $stmt->rowCount() > 0;
}
public function borrar(int $id): bool
{
$stmt = $this->pdo->prepare('DELETE FROM productos WHERE id = ?');
$stmt->execute([$id]);
return $stmt->rowCount() > 0;
}
}
Uso desde cualquier página:
<?php
$repo = new App\Repository\ProductoRepository(conexion());
$pagina = max(1, (int) ($_GET['pagina'] ?? 1));
$productos = $repo->buscar($_GET['q'] ?? '', $pagina);
$total = $repo->contar($_GET['q'] ?? '');
💡 Consejo: Fíjate en que el repositorio recibe el objeto
PDOen el constructor en lugar de crearlo. Esto se llama inyección de dependencias y permite, por ejemplo, pasarle una base de datos SQLite en memoria en los tests (módulo 18).
14. Subclases de PDO por driver (PHP 8.4)
PHP 8.4 añadió clases específicas para cada driver, que heredan de PDO: Pdo\Mysql, Pdo\Sqlite, Pdo\Pgsql, Pdo\Firebird, Pdo\Odbc y Pdo\Dblib. Cada una incluye los métodos y constantes propios de su base de datos.
<?php
declare(strict_types=1);
// Crear directamente la subclase
$pdo = new Pdo\Mysql('mysql:host=localhost;dbname=tienda;charset=utf8mb4', 'tienda', 'secreto');
// O dejar que PDO::connect() elija la subclase según el DSN
$db = PDO::connect('sqlite::memory:');
var_dump($db instanceof Pdo\Sqlite); // bool(true)
// Métodos propios de SQLite, ahora en su clase
$db->createFunction('mayusculas', fn(string $s): string => mb_strtoupper($s), 1);
⚠️ Importante: En PHP 8.5 las constantes específicas de driver de la clase
PDOestán obsoletas: usaPdo\Mysql::ATTR_INIT_COMMANDen lugar dePDO::MYSQL_ATTR_INIT_COMMAND,Pdo\Mysql::ATTR_SSL_CAen lugar dePDO::MYSQL_ATTR_SSL_CA, etc. Las constantes genéricas (PDO::ATTR_ERRMODE,PDO::FETCH_ASSOC…) no cambian.
15. SQLite: una base de datos sin servidor
SQLite guarda toda la base de datos en un único fichero y no necesita instalar ni arrancar ningún servidor. La extensión pdo_sqlite viene con PHP.
| MySQL / MariaDB | SQLite | |
|---|---|---|
| Servidor | Sí, proceso aparte | No, es un fichero |
| Usuarios y permisos | Sí | No (permisos del fichero) |
| Concurrencia de escritura | Alta | Limitada (una escritura a la vez) |
| Ideal para | Webs con muchos usuarios | Prototipos, tests, apps pequeñas, apps de escritorio |
<?php
declare(strict_types=1);
$pdo = new PDO('sqlite:' . __DIR__ . '/tienda.sqlite', options: [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
// En SQLite las claves foráneas hay que activarlas en cada conexión
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec('
CREATE TABLE IF NOT EXISTS notas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
texto TEXT NOT NULL,
creada_en TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
');
$stmt = $pdo->prepare('INSERT INTO notas (texto) VALUES (?)');
$stmt->execute(['Comprar café']);
$stmt->execute(['Estudiar PDO']);
foreach ($pdo->query('SELECT * FROM notas') as $nota) {
echo "#{$nota['id']} {$nota['texto']} ({$nota['creada_en']})", PHP_EOL;
}
// #1 Comprar café (2026-10-03 08:12:45)
// #2 Estudiar PDO (2026-10-03 08:12:45)
💡 Consejo: El DSN especial
sqlite::memory:crea una base de datos en memoria que desaparece al terminar el script. Es perfecta para los tests automáticos: rapidísima y siempre limpia. Y como usas PDO, tuProductoRepositoryfunciona igual con SQLite que con MySQL (siempre que el SQL sea compatible).
16. Errores comunes
| Error | Causa | Solución |
|---|---|---|
could not find driver | Falta la extensión pdo_mysql | Actívala en php.ini y reinicia el servidor |
SQLSTATE[HY000] [2002] Connection refused | MySQL no está arrancado o el host/puerto es incorrecto | Arranca el servicio; prueba 127.0.0.1 en lugar de localhost |
SQLSTATE[HY000] [1045] Access denied | Usuario o contraseña incorrectos | Revisa las credenciales y los permisos (GRANT) |
SQLSTATE[42S02] Base table not found | La tabla no existe o la base de datos no es la correcta | Revisa dbname en el DSN y ejecuta el script SQL |
SQLSTATE[23000] Integrity constraint violation | Duplicado en un UNIQUE o clave foránea inexistente | Valida antes o captura el error y muestra un mensaje claro |
SQLSTATE[HY093] Invalid parameter number | El número de parámetros no coincide con los huecos | Revisa que cada :nombre o ? tenga su valor |
Tildes como á | Falta charset=utf8mb4 | Añádelo al DSN y usa utf8mb4 en las tablas |
Error de sintaxis en LIMIT '10' | Valor enviado como cadena con prepares emulados | Usa bindValue(..., PDO::PARAM_INT) |
17. Ejercicios
Ejercicio 1: Conexión y diagnóstico
Crea config/database.php y una función conexion() como la del módulo. Escribe un script diagnostico.php que muestre: la versión del servidor (PDO::ATTR_SERVER_VERSION), el driver (PDO::ATTR_DRIVER_NAME), el número de productos y el número de categorías. Si la conexión falla, debe mostrar un mensaje amable y registrar el detalle con error_log().
Ejercicio 2: Informe por categorías
Escribe una página que muestre, en una tabla HTML, cada categoría con: número de productos, stock total y precio medio (formateado con dos decimales y coma). Usa una única consulta con JOIN y GROUP BY. Incluye también las categorías sin productos (pista: LEFT JOIN).
Ejercicio 3: CRUD de categorías
Implementa el CRUD completo de la tabla categorias (listar, crear, editar, borrar). Al crear o editar, si el nombre ya existe (error 23000 por el UNIQUE), muestra “Ya existe una categoría con ese nombre” junto al campo. Al borrar una categoría con productos, muestra un mensaje en lugar de un error.
Ejercicio 4: Listado completo con búsqueda, orden y paginación
Amplía el index.php de productos para que tenga a la vez: búsqueda por nombre (con LIKE seguro), filtro por categoría (un <select>), ordenación por nombre, precio o stock (ascendente y descendente, con lista blanca) y paginación de 5 en 5. Los enlaces de paginación deben conservar la búsqueda, el filtro y el orden.
Ejercicio 5: Repository con objetos
Crea una clase final readonly class Producto con constructor promovido y un método estático desdeFila(). Modifica ProductoRepository para que buscarPorId() devuelva ?Producto y buscar() devuelva list<Producto>. Añade un método guardar(Producto $p): Producto que haga INSERT si el id es null y UPDATE en caso contrario.
Ejercicio 6: Transacción de reposición
Escribe una función reponer(PDO $pdo, array $entradas): void que reciba [producto_id => cantidad] y aumente el stock de todos dentro de una transacción. Si algún producto_id no existe, ninguna reposición debe aplicarse. Demuéstralo ejecutándola con un id inexistente y comprobando que el stock de los demás no ha cambiado.
Ejercicio 7: Migrar a SQLite
Crea una versión SQLite del script tienda.sql (adapta AUTO_INCREMENT, UNSIGNED, ENGINE, etc.). Haz que tu aplicación elija el driver según una variable de configuración driver (mysql o sqlite) y comprueba que el ProductoRepository funciona sin cambios con ambos.
Resumen
| Concepto | Sintaxis / Clave |
|---|---|
| DSN MySQL | mysql:host=localhost;dbname=tienda;charset=utf8mb4 |
| DSN SQLite | sqlite:/ruta/db.sqlite o sqlite::memory: |
| Conectar | new PDO($dsn, $usuario, $clave, $opciones) |
| Opciones clave | ERRMODE_EXCEPTION, FETCH_ASSOC, EMULATE_PREPARES => false |
| Consulta fija | $pdo->query('SELECT ...') |
| Consulta con datos | $stmt = $pdo->prepare('... WHERE id = ?'); $stmt->execute([$id]); |
| Parámetros con nombre | :nombre + execute(['nombre' => $valor]) |
| Tipar un parámetro | bindValue(':limite', 10, PDO::PARAM_INT) |
| Una fila / todas / un valor | fetch(), fetchAll(), fetchColumn() |
| Objetos | fetchAll(PDO::FETCH_CLASS, Producto::class) |
| Pares clave-valor | fetchAll(PDO::FETCH_KEY_PAIR) |
| Id generado | $pdo->lastInsertId() |
| Filas afectadas | $stmt->rowCount() |
| Paginación | LIMIT :limite OFFSET :desplazamiento |
LIKE seguro | '%' . addcslashes($q, '%_\\') . '%' |
| Transacción | beginTransaction(), commit(), rollBack() |
| Subclases (8.4) | Pdo\Mysql, Pdo\Sqlite, PDO::connect($dsn) |
| Repository | Clase que encapsula el SQL de una entidad y recibe el PDO |