🗄️ PHP + MySQL: CRUD completo con PDO

¿Qué es PDO?

PDO (PHP Data Objects) es una extensión que permite acceder a bases de datos de forma uniforme. Ventajas frente a mysqli:

  • Soporta múltiples motores: MySQL, PostgreSQL, SQLite, Oracle...
  • Prepared statements con seguridad integrada contra inyección SQL
  • Manejo de errores con excepciones
  • API orientada a objetos moderna

Conexión a la base de datos

<?php
// config.php — Configuración de la base de datos
$host = "localhost";
$dbname = "tienda";
$username = "root";
$password = "";
$charset = "utf8mb4";

try {
    $dsn = "mysql:host=$host;dbname=$dbname;charset=$charset";
    $options = [
        PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION, // Lanzar excepciones
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,       // Array asociativo
        PDO::ATTR_EMULATE_PREPARES   => false,                  // Prepared statements reales
    ];

    $pdo = new PDO($dsn, $username, $password, $options);
    echo "Conectado a $dbname";
} catch (PDOException $e) {
    // En producción, no mostrar detalles del error al usuario
    error_log("Error de conexión: " . $e->getMessage());
    die("Error al conectar con la base de datos");
}
?>

Prepared statements

Los prepared statements separan la consulta SQL de los datos, eliminando el riesgo de inyección SQL:

<?php
// MAL: inyección SQL directa (¡NUNCA HAGAS ESTO!)
// $sql = "SELECT * FROM usuarios WHERE email = '" . $_POST["email"] . "'";

// BIEN: prepared statement con marcadores (?)
$stmt = $pdo->prepare("SELECT * FROM usuarios WHERE email = ?");
$stmt->execute([$_POST["email"]]);
$usuario = $stmt->fetch();

// BIEN: prepared statement con marcadores nombrados (:param)
$stmt = $pdo->prepare("SELECT * FROM productos WHERE precio > :min AND precio < :max");
$stmt->execute(["min" => 10, "max" => 100]);
$productos = $stmt->fetchAll();
?>

CREATE (Insertar datos)

<?php
function insertar_producto(PDO $pdo, string $nombre, float $precio, int $stock): int {
    $sql = "INSERT INTO productos (nombre, precio, stock, creado_en) VALUES (?, ?, ?, NOW())";
    $stmt = $pdo->prepare($sql);
    $stmt->execute([$nombre, $precio, $stock]);
    return (int) $pdo->lastInsertId(); // Devuelve el ID generado
}

// Uso:
$id = insertar_producto($pdo, "Teclado mecánico", 89.99, 15);
echo "Producto creado con ID: $id";
?>

READ (Leer datos)

<?php
// Obtener todos los productos
function obtener_productos(PDO $pdo): array {
    $stmt = $pdo->query("SELECT * FROM productos ORDER BY creado_en DESC");
    return $stmt->fetchAll();
}

// Obtener un producto por ID
function obtener_producto_por_id(PDO $pdo, int $id): ?array {
    $sql = "SELECT * FROM productos WHERE id = ?";
    $stmt = $pdo->prepare($sql);
    $stmt->execute([$id]);
    $producto = $stmt->fetch();
    return $producto ?: null; // Devuelve null si no existe
}

// Mostrar productos en una tabla HTML
$productos = obtener_productos($pdo);
?>
<table>
    <thead>
        <tr><th>ID</th><th>Nombre</th><th>Precio</th><th>Stock</th><th>Acciones</th></tr>
    </thead>
    <tbody>
        <?php foreach ($productos as $p): ?>
        <tr>
            <td><?= $p["id"] ?></td>
            <td><?= htmlspecialchars($p["nombre"]) ?></td>
            <td><?= number_format($p["precio"], 2) ?>€</td>
            <td><?= $p["stock"] ?></td>
            <td>
                <a href="editar.php?id=<?= $p["id"] ?>">Editar</a>
                <a href="eliminar.php?id=<?= $p["id"] ?>" onclick="return confirm('¿Seguro?')">Eliminar</a>
            </td>
        </tr>
        <?php endforeach; ?>
    </tbody>
</table>

UPDATE (Actualizar datos)

<?php
function actualizar_producto(PDO $pdo, int $id, string $nombre, float $precio, int $stock): bool {
    $sql = "UPDATE productos SET nombre = ?, precio = ?, stock = ? WHERE id = ?";
    $stmt = $pdo->prepare($sql);
    return $stmt->execute([$nombre, $precio, $stock, $id]);
}

// Formulario de edición
$id = (int) ($_GET["id"] ?? 0);
$producto = obtener_producto_por_id($pdo, $id);
if (!$producto) {
    die("Producto no encontrado");
}

if ($_SERVER["REQUEST_METHOD"] === "POST") {
    $nombre = trim($_POST["nombre"] ?? "");
    $precio = (float) ($_POST["precio"] ?? 0);
    $stock = (int) ($_POST["stock"] ?? 0);

    if (actualizar_producto($pdo, $id, $nombre, $precio, $stock)) {
        header("Location: listar.php?mensaje=Actualizado correctamente");
        exit;
    }
}
?>

<form method="POST">
    <label>Nombre: <input type="text" name="nombre" value="<?= htmlspecialchars($producto["nombre"]) ?>" required></label><br>
    <label>Precio: <input type="number" step="0.01" name="precio" value="<?= $producto["precio"] ?>" required></label><br>
    <label>Stock: <input type="number" name="stock" value="<?= $producto["stock"] ?>" required></label><br>
    <button type="submit">Guardar cambios</button>
</form>

DELETE (Eliminar datos)

<?php
function eliminar_producto(PDO $pdo, int $id): bool {
    $sql = "DELETE FROM productos WHERE id = ?";
    $stmt = $pdo->prepare($sql);
    return $stmt->execute([$id]);
}

// eliminar.php
if (isset($_GET["id"])) {
    $id = (int) $_GET["id"];
    if (eliminar_producto($pdo, $id)) {
        header("Location: listar.php?mensaje=Producto eliminado");
        exit;
    } else {
        echo "Error al eliminar";
    }
}
?>

Búsqueda

<?php
function buscar_productos(PDO $pdo, string $termino): array {
    $sql = "SELECT * FROM productos WHERE nombre LIKE ? OR descripcion LIKE ?";
    $like = "%$termino%";
    $stmt = $pdo->prepare($sql);
    $stmt->execute([$like, $like]);
    return $stmt->fetchAll();
}

// Búsqueda avanzada con filtros
function filtrar_productos(PDO $pdo, ?string $categoria, ?float $precio_min, ?float $precio_max): array {
    $sql = "SELECT * FROM productos WHERE 1=1";
    $params = [];

    if ($categoria) {
        $sql .= " AND categoria = ?";
        $params[] = $categoria;
    }
    if ($precio_min !== null) {
        $sql .= " AND precio >= ?";
        $params[] = $precio_min;
    }
    if ($precio_max !== null) {
        $sql .= " AND precio <= ?";
        $params[] = $precio_max;
    }

    $sql .= " ORDER BY precio ASC";
    $stmt = $pdo->prepare($sql);
    $stmt->execute($params);
    return $stmt->fetchAll();
}
?>

<!-- Formulario de búsqueda -->
<form method="GET">
    <input type="text" name="q" placeholder="Buscar productos..." value="<?= htmlspecialchars($_GET["q"] ?? "") ?>">
    <button type="submit">Buscar</button>
</form>

<?php
if (isset($_GET["q"]) && trim($_GET["q"]) !== "") {
    $resultados = buscar_productos($pdo, $_GET["q"]);
    echo "<p>Se encontraron " . count($resultados) . " resultados</p>";
}
?>

Paginación

<?php
function obtener_productos_paginados(PDO $pdo, int $pagina = 1, int $por_pagina = 10): array {
    $offset = ($pagina - 1) * $por_pagina;

    // Total de registros
    $total = (int) $pdo->query("SELECT COUNT(*) FROM productos")->fetchColumn();
    $total_paginas = (int) ceil($total / $por_pagina);

    // Registros de la página actual
    $sql = "SELECT * FROM productos ORDER BY creado_en DESC LIMIT ? OFFSET ?";
    $stmt = $pdo->prepare($sql);
    $stmt->execute([$por_pagina, $offset]);
    $productos = $stmt->fetchAll();

    return [
        "productos"     => $productos,
        "total"         => $total,
        "pagina_actual" => $pagina,
        "total_paginas" => $total_paginas,
        "por_pagina"    => $por_pagina,
    ];
}

$pagina = max(1, (int) ($_GET["pagina"] ?? 1));
$datos = obtener_productos_paginados($pdo, $pagina, 5);
$productos = $datos["productos"];
?>

<!-- Mostrar productos (igual que antes) -->

<!-- Paginación -->
<nav class="paginacion">
    <?php if ($datos["pagina_actual"] > 1): ?>
        <a href="?pagina=<?= $datos["pagina_actual"] - 1 ?>">« Anterior</a>
    <?php endif; ?>

    <?php for ($i = 1; $i <= $datos["total_paginas"]; $i++): ?>
        <a href="?pagina=<?= $i ?>" class="<?= $i === $datos["pagina_actual"] ? 'activa' : '' ?>">
            <?= $i ?>
        </a>
    <?php endfor; ?>

    <?php if ($datos["pagina_actual"] < $datos["total_paginas"]): ?>
        <a href="?pagina=<?= $datos["pagina_actual"] + 1 ?>">Siguiente »</a>
    <?php endif; ?>
</nav>

<p>Página <?= $datos["pagina_actual"] ?> de <?= $datos["total_paginas"] ?>
(<?= $datos["total"] ?> productos en total)</p>

Transacciones

Para operaciones que deben ejecutarse juntas (todo o nada):

<?php
try {
    // Iniciar transacción
    $pdo->beginTransaction();

    // Descontar stock
    $stmt1 = $pdo->prepare("UPDATE productos SET stock = stock - ? WHERE id = ? AND stock >= ?");
    $stmt1->execute([$cantidad, $producto_id, $cantidad]);

    if ($stmt1->rowCount() === 0) {
        throw new Exception("Stock insuficiente");
    }

    // Registrar pedido
    $stmt2 = $pdo->prepare("INSERT INTO pedidos (producto_id, cantidad, total) VALUES (?, ?, ?)");
    $stmt2->execute([$producto_id, $cantidad, $precio * $cantidad]);

    // Confirmar cambios
    $pdo->commit();
    echo "Pedido realizado con éxito";
} catch (Exception $e) {
    // Revertir todos los cambios si algo falla
    $pdo->rollBack();
    error_log("Error en pedido: " . $e->getMessage());
    echo "Error al procesar el pedido";
}
?>

Estructura MVC básica

Separación en Modelo, Vista y Controlador para organizar el código:

// Estructura de carpetas
// proyecto/
// ├── config/
// │   └── database.php       # Conexión PDO
// ├── modelos/
// │   └── Producto.php       # Consultas a la BD
// ├── controladores/
// │   └── ProductoController.php  # Lógica de negocio
// ├── vistas/
// │   ├── listar.php         # HTML (solo mostrar)
// │   └── formulario.php     # HTML (formularios)
// └── index.php              # Punto de entrada
<?php
// modelos/Producto.php — Modelo (acceso a datos)
namespace App\Modelos;

class Producto {
    private PDO $pdo;

    public function __construct(PDO $pdo) {
        $this->pdo = $pdo;
    }

    public function obtenerTodos(): array {
        $stmt = $this->pdo->query("SELECT * FROM productos ORDER BY id DESC");
        return $stmt->fetchAll();
    }

    public function obtenerPorId(int $id): ?array {
        $stmt = $this->pdo->prepare("SELECT * FROM productos WHERE id = ?");
        $stmt->execute([$id]);
        return $stmt->fetch() ?: null;
    }

    public function crear(string $nombre, float $precio, int $stock): int {
        $stmt = $this->pdo->prepare("INSERT INTO productos (nombre, precio, stock, creado_en) VALUES (?, ?, ?, NOW())");
        $stmt->execute([$nombre, $precio, $stock]);
        return (int) $this->pdo->lastInsertId();
    }

    public function actualizar(int $id, string $nombre, float $precio, int $stock): bool {
        $stmt = $this->pdo->prepare("UPDATE productos SET nombre = ?, precio = ?, stock = ? WHERE id = ?");
        return $stmt->execute([$nombre, $precio, $stock, $id]);
    }

    public function eliminar(int $id): bool {
        $stmt = $this->pdo->prepare("DELETE FROM productos WHERE id = ?");
        return $stmt->execute([$id]);
    }

    public function buscar(string $termino): array {
        $like = "%$termino%";
        $stmt = $this->pdo->prepare("SELECT * FROM productos WHERE nombre LIKE ? OR descripcion LIKE ?");
        $stmt->execute([$like, $like]);
        return $stmt->fetchAll();
    }
}
?>
<?php
// controladores/ProductoController.php — Controlador (lógica)
namespace App\Controladores;

use App\Modelos\Producto;

class ProductoController {
    private Producto $modelo;

    public function __construct(Producto $modelo) {
        $this->modelo = $modelo;
    }

    public function index(): void {
        $productos = $this->modelo->obtenerTodos();
        require __DIR__ . "/../vistas/listar.php";
    }

    public function crear(): void {
        if ($_SERVER["REQUEST_METHOD"] === "POST") {
            $id = $this->modelo->crear(
                trim($_POST["nombre"]),
                (float) $_POST["precio"],
                (int) $_POST["stock"]
            );
            header("Location: ?accion=ver&id=$id");
            exit;
        }
        require __DIR__ . "/../vistas/formulario.php";
    }

    public function editar(int $id): void {
        $producto = $this->modelo->obtenerPorId($id);
        if (!$producto) {
            die("Producto no encontrado");
        }

        if ($_SERVER["REQUEST_METHOD"] === "POST") {
            $this->modelo->actualizar(
                $id,
                trim($_POST["nombre"]),
                (float) $_POST["precio"],
                (int) $_POST["stock"]
            );
            header("Location: ?accion=index&msg=Actualizado");
            exit;
        }
        require __DIR__ . "/../vistas/formulario.php";
    }

    public function eliminar(int $id): void {
        $this->modelo->eliminar($id);
        header("Location: ?accion=index&msg=Eliminado");
        exit;
    }
}

// index.php (punto de entrada)
require_once __DIR__ . "/config/database.php";
require_once __DIR__ . "/vendor/autoload.php";

use App\Modelos\Producto;
use App\Controladores\ProductoController;

$pdo = obtenerConexion();
$modelo = new Producto($pdo);
$controlador = new ProductoController($modelo);

// Enrutamiento simple
$accion = $_GET["accion"] ?? "index";
$id = (int) ($_GET["id"] ?? 0);

switch ($accion) {
    case "crear":
        $controlador->crear();
        break;
    case "editar":
        $controlador->editar($id);
        break;
    case "eliminar":
        $controlador->eliminar($id);
        break;
    case "ver":
        $producto = $modelo->obtenerPorId($id);
        require "vistas/detalle.php";
        break;
    default:
        $controlador->index();
        break;
}
?>

Ejemplo completo: esquema SQL

CREATE DATABASE tienda CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

USE tienda;

CREATE TABLE productos (
    id INT PRIMARY KEY AUTO_INCREMENT,
    nombre VARCHAR(150) NOT NULL,
    descripcion TEXT,
    precio DECIMAL(10, 2) NOT NULL,
    stock INT NOT NULL DEFAULT 0,
    categoria VARCHAR(50),
    creado_en DATETIME DEFAULT CURRENT_TIMESTAMP,
    actualizado_en DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

-- Datos de ejemplo
INSERT INTO productos (nombre, precio, stock, categoria) VALUES
('Portátil HP', 799.99, 10, 'informática'),
('Teclado RGB', 59.99, 25, 'periféricos'),
('Monitor 27"', 299.99, 8, 'informática'),
('Ratón inalámbrico', 34.99, 30, 'periféricos'),
('Auriculares Bluetooth', 89.99, 15, 'audio');

Manejo de errores en PDO

<?php
try {
    $pdo = new PDO($dsn, $user, $pass, [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
    ]);
} catch (PDOException $e) {
    // Loggear el error real
    error_log("DB Error: " . $e->getMessage());
    // Mensaje genérico al usuario
    die("Error de conexión. Inténtelo más tarde.");
}

// Capturar errores específicos de SQL
try {
    $stmt = $pdo->prepare("INSERT INTO productos (nombre) VALUES (?)");
    $stmt->execute(["Teclado"]);
} catch (PDOException $e) {
    if ($e->getCode() == 23000) { // Código para violación de constraint
        echo "Ese registro ya existe.";
    } else {
        throw $e; // Re-lanzar si es otro error
    }
}
?>

Ejercicios

  1. Crea la base de datos tienda con la tabla productos del esquema anterior.
  2. Implementa un script PHP que muestre todos los productos en una tabla HTML con PDO.
  3. Añade funcionalidad de crear nuevo producto con formulario y prepared statements.
  4. Implementa búsqueda por nombre y paginación (10 productos por página).
  5. Refactoriza el código en estructura MVC (modelo/controlador/vista) como se muestra arriba.

← Volver al curso