Módulo 15: Bases de datos con PDO

Objetivos del módulo

  • Repasar el SQL mínimo necesario: CREATE, INSERT, SELECT, UPDATE, DELETE y JOIN
  • 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() de prepare()/execute() y usar parámetros posicionales y con nombre
  • Recuperar datos con fetch(), fetchAll(), fetchColumn() y FETCH_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 UPDATE o DELETE sin WHERE afecta a todas las filas de la tabla. Antes de ejecutarlos, prueba el mismo WHERE con un SELECT para 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

  1. Abre el panel de control y arranca MySQL (en XAMPP) o pulsa Start All (en Laragon).
  2. Con XAMPP abre http://localhost/phpmyadmin. Con Laragon pulsa Database para abrir HeidiSQL.
  3. El usuario por defecto es root sin 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: utf8mb4 es el juego de caracteres que admite todo Unicode, incluidos emojis y caracteres de cualquier idioma. El antiguo utf8 de MySQL solo admitía 3 bytes por carácter y no sirve para todo. Usa siempre utf8mb4.


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. mysqli solo 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 una PDOException al 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 static dentro de la función hace que la conexión se cree una sola vez por petición, aunque llames a conexion() 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 :nombre en la misma consulta. Y los parámetros sirven solo para valores, no para nombres de tablas, columnas o palabras clave como ASC/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 del execute(). Es útil para ejecutar la misma sentencia muchas veces en un bucle. En el resto de casos, bindValue() o execute([...]) 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 DECIMAL llegan como cadena ("289.00"), porque PHP no tiene un tipo decimal exacto y convertirlas a float podrí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 propiedades readonly promovidas 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 un UPDATE cuenta las filas realmente modificadas: si actualizas una fila con los mismos valores que ya tenía, devuelve 0. Y no te fíes de rowCount() tras un SELECT: no todos los drivers lo soportan. Para contar, usa COUNT(*).


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.php que 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 m devuelve como máximo n filas saltándose las m primeras. Para la página p (empezando en 1) con n elementos 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: OFFSET se 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 normal LIMIT/OFFSET es 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 = false no puedes repetir el mismo parámetro con nombre (:q dos veces) en una consulta: PDO lanza un error de número de parámetros. Por eso el ejemplo usa :q y :q2 con 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 TABLE o ALTER 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 PDO en 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 PDO están obsoletas: usa Pdo\Mysql::ATTR_INIT_COMMAND en lugar de PDO::MYSQL_ATTR_INIT_COMMAND, Pdo\Mysql::ATTR_SSL_CA en lugar de PDO::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, tu ProductoRepository funciona 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