Hay dos maneras de enviar a la base de datos el correo que alguien escribió en un formulario. La primera es pegarlo dentro del texto SQL: "SELECT * FROM clientes WHERE email = '$email'". La segunda es mandar la consulta por un lado y el dato por otro, para que el motor nunca los confunda. PDO, la capa de acceso a datos que viene con PHP, está pensada para trabajar de la segunda forma, y además te da la misma interfaz para MySQL, MariaDB, PostgreSQL, SQLite y otros motores.

Este tutorial construye, pieza a pieza, el código que necesita una aplicación real: una conexión bien configurada, consultas preparadas, los modos de lectura de resultados, una transacción con bloqueo de filas y un manejo de errores que distingue un correo duplicado de una caída del servidor. Todo el código está escrito para PHP 8.1 o posterior.
Requisitos: la extensión correcta para tu motor
PDO es solo la interfaz común. Cada motor necesita su propio driver: pdo_mysql para MySQL y MariaDB, pdo_pgsql para PostgreSQL. En Linux suelen instalarse como paquetes aparte (por ejemplo, php8.3-mysql o php8.3-pgsql en distribuciones basadas en Debian), y en Windows basta con quitar el punto y coma de la línea extension=pdo_mysql o extension=pdo_pgsql en php.ini. Si todavía no tienes el entorno listo, revisa primero la guía para instalar PHP 8 y Composer en Windows, macOS y Linux.
Para comprobar qué drivers tienes disponibles, ejecuta esto desde la terminal:
php -r "print_r(PDO::getAvailableDrivers());"
Si la lista no incluye mysql o pgsql, cualquier intento de conexión terminará con el mensaje could not find driver, que no tiene nada que ver con tu código.
Paso 1: una conexión con las opciones que importan
La conexión se crea con un DSN (una cadena que indica driver, servidor y base de datos), el usuario, la contraseña y un array de opciones. Las opciones son la parte que más se olvida y la que más problemas evita después.
<?php
declare(strict_types=1);
function conectar(): PDO
{
// MySQL / MariaDB: el charset va en el DSN
$dsn = 'mysql:host=127.0.0.1;port=3306;dbname=tienda;charset=utf8mb4';
// PostgreSQL usaría algo como:
// $dsn = 'pgsql:host=127.0.0.1;port=5432;dbname=tienda';
$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, // preparación real en el servidor
];
// Las credenciales salen del entorno, no del código fuente
$usuario = getenv('DB_USER') ?: '';
$clave = getenv('DB_PASS') ?: '';
return new PDO($dsn, $usuario, $clave, $opciones);
}
Qué hace cada opción y por qué conviene declararla:
ATTR_ERRMODE => ERRMODE_EXCEPTION: cualquier error de SQL lanza unaPDOException. Desde PHP 8.0 este ya es el valor por defecto, pero escribirlo deja clara la intención y protege el código si alguien cambia la configuración en otro punto. En versiones antiguas el modo por defecto era silencioso: la consulta fallaba,execute()devolvíafalsey nadie se enteraba.ATTR_DEFAULT_FETCH_MODE => FETCH_ASSOC: sin esta opción, cada fila llega duplicada con claves numéricas y de texto (FETCH_BOTH), lo que ocupa más memoria y ensucia cualquiervar_dump().ATTR_EMULATE_PREPARES => false: con el driver de MySQL, PDO emula las consultas preparadas por defecto, es decir, arma el SQL final en PHP escapando los valores. Al desactivar la emulación, la consulta y los datos viajan por separado al servidor. Con PostgreSQL la preparación nativa ya es lo habitual, pero declararlo no hace daño.
Sobre el charset: en MySQL usa utf8mb4, no utf8. El antiguo utf8 de MySQL solo admite caracteres de hasta tres bytes y falla con emojis y algunos símbolos. Si ves tildes convertidas en signos raros, lo primero que hay que revisar es esta parte del DSN y la codificación de las tablas.
Qué hacer si la conexión falla
Si el servidor está caído o las credenciales son incorrectas, el constructor lanza una PDOException. El mensaje de esa excepción puede incluir el host, el nombre de usuario u otros detalles internos, así que no debe llegar nunca a la pantalla del visitante. Regístralo en el log y muestra un mensaje genérico:
<?php
try {
$pdo = conectar();
} catch (PDOException $e) {
error_log('Fallo de conexión a la BD: ' . $e->getMessage());
http_response_code(503);
exit('El servicio no está disponible en este momento.');
}
Paso 2: consultas preparadas
Una consulta preparada tiene dos fases. Con prepare() envías el SQL con marcadores en lugar de valores. Con execute() envías los valores. El motor ya sabe qué parte es código y qué parte es dato, de modo que un apóstrofo en un apellido como O’Neill no rompe nada.
Hay dos estilos de marcadores. Los nombrados (:categoria) se leen mejor cuando hay varios parámetros. Los posicionales (?) son más cortos para consultas simples. No se pueden mezclar en la misma consulta.
<?php
// Marcadores nombrados: las claves del array pueden ir con o sin los dos puntos
$stmt = $pdo->prepare(
'SELECT id, nombre, precio
FROM productos
WHERE categoria_id = :categoria AND precio <= :maximo'
);
$stmt->execute(['categoria' => 4, 'maximo' => 50000]);
$productos = $stmt->fetchAll(); // array de filas (asociativas, por la opción por defecto)
// Marcadores posicionales: el orden del array debe coincidir con el de los ?
$stmt = $pdo->prepare('SELECT nombre FROM productos WHERE id = ?');
$stmt->execute([$id]);
$nombre = $stmt->fetchColumn(); // valor de la primera columna, o false si no hay fila
Una ventaja adicional: puedes preparar una vez y ejecutar muchas veces con datos distintos. Para insertar cientos de filas, esto evita que el motor analice el mismo SQL en cada iteración.
bindValue, bindParam y el caso de LIMIT
Pasar un array a execute() trata todos los valores como texto. Casi siempre da igual, porque el motor convierte el tipo. Pero hay cláusulas donde no: si alguna vez activas la emulación, LIMIT '10' es un error de sintaxis en MySQL. La solución que funciona en cualquier configuración es indicar el tipo con bindValue():
<?php
$porPagina = 20;
$pagina = max(1, (int) ($_GET['pagina'] ?? 1));
$stmt = $pdo->prepare('SELECT id, nombre FROM productos ORDER BY id LIMIT :limite OFFSET :desde');
$stmt->bindValue(':limite', $porPagina, PDO::PARAM_INT);
$stmt->bindValue(':desde', ($pagina - 1) * $porPagina, PDO::PARAM_INT);
$stmt->execute();
bindParam() se parece, pero enlaza la variable por referencia y lee su valor en el momento de execute(), no en el de la llamada. Es útil en bucles donde preparas una vez y solo cambias la variable; fuera de ese caso, bindValue() es más predecible.
Listas variables para IN (…)
Un marcador representa un solo valor, nunca una lista. Para filtrar por varios identificadores hay que generar tantos ? como elementos haya:
<?php
// $idsRecibidos llega de un formulario, por ejemplo ['3', '8', 'x', '15']
$ids = array_map('intval', $idsRecibidos);
$ids = array_values(array_filter($ids, fn (int $id) => $id > 0)); // reindexar es obligatorio
if ($ids === []) {
$productos = []; // IN () vacío es SQL inválido: se resuelve antes de consultar
} else {
$marcas = implode(', ', array_fill(0, count($ids), '?'));
$stmt = $pdo->prepare("SELECT id, nombre FROM productos WHERE id IN ($marcas)");
$stmt->execute($ids);
$productos = $stmt->fetchAll();
}
El array_values() no es decorativo: array_filter() conserva las claves originales, y si el array llega con claves 0, 2 y 5, PDO responde con Invalid parameter number. Si quieres repasar qué funciones de arrays conservan claves y cuáles las renumeran, tienes las fichas en arrays en PHP: funciones esenciales con ejemplos.
Importante: los marcadores solo sustituyen valores. Nombres de tablas, nombres de columnas y palabras como ASC o DESC no pueden ir como parámetros. Si necesitas ordenar por una columna que elige el usuario, compara su elección con una lista cerrada de columnas permitidas.
Paso 3: insertar y recuperar el id generado
Aquí MySQL y PostgreSQL se comportan distinto. En MySQL lo habitual es lastInsertId(); en PostgreSQL la forma más clara es pedir el id en la misma consulta con RETURNING:
<?php
$datos = ['nombre' => $nombre, 'email' => $email];
// MySQL / MariaDB
$stmt = $pdo->prepare('INSERT INTO clientes (nombre, email) VALUES (:nombre, :email)');
$stmt->execute($datos);
$idNuevo = (int) $pdo->lastInsertId(); // devuelve un string: se convierte a int
// PostgreSQL
$stmt = $pdo->prepare('INSERT INTO clientes (nombre, email) VALUES (:nombre, :email) RETURNING id');
$stmt->execute($datos);
$idNuevo = (int) $stmt->fetchColumn();
Paso 4: elegir el modo de fetch adecuado
El modo de fetch decide la forma en que recibes cada fila. Elegir bien ahorra bucles de transformación posteriores. Puedes pasarlo a fetch() o fetchAll() para una consulta concreta, aunque hayas fijado otro por defecto.
| Modo | Qué devuelve | Cuándo usarlo |
|---|---|---|
FETCH_ASSOC | ['id' => 7, 'nombre' => 'Café'] | El caso general; ideal como modo por defecto |
FETCH_NUM | [7, 'Café'] | Desestructurar con [$id, $nombre] = $fila |
FETCH_BOTH | Las dos anteriores juntas | Casi nunca; es el valor por defecto si no configuras otro |
FETCH_OBJ | Objeto stdClass con propiedades | Plantillas antiguas que usan $fila->nombre |
FETCH_COLUMN | Solo una columna por fila | Obtener una lista simple de ids o correos |
FETCH_KEY_PAIR | [7 => 'Café', 9 => 'Té'] | Opciones de un <select>; exige exactamente dos columnas |
FETCH_GROUP | Filas agrupadas por la primera columna | Listados por categoría sin bucles manuales |
FETCH_CLASS | Instancias de una clase tuya | Mapear filas a objetos; requiere cuidado con los tipos |
<?php
// Lista simple de correos
$correos = $pdo->query('SELECT email FROM clientes')->fetchAll(PDO::FETCH_COLUMN);
// id => nombre, listo para un <select>
$opciones = $pdo->query('SELECT id, nombre FROM categorias ORDER BY nombre')
->fetchAll(PDO::FETCH_KEY_PAIR);
// ['Bebidas' => ['Café', 'Té'], 'Panadería' => ['Pan', 'Arepa']]
$porCategoria = $pdo->query(
'SELECT c.nombre, p.nombre
FROM productos p
JOIN categorias c ON c.id = p.categoria_id'
)->fetchAll(PDO::FETCH_GROUP | PDO::FETCH_COLUMN);
Estos ejemplos usan query() porque no llevan datos externos. En cuanto la consulta incluya algo que venga del usuario, de una cookie o de otra API, vuelve a prepare().
De fila a objeto sin sorpresas de tipos
FETCH_CLASS asigna cada columna a la propiedad del mismo nombre, pero el tipo con el que llegan los valores depende del driver, de la emulación y del tipo de columna. Las columnas DECIMAL o NUMERIC, por ejemplo, llegan como string en MySQL y en PostgreSQL para no perder precisión. Si trabajas con declare(strict_types=1), que explicamos en tipos, variables y strict_types en PHP 8, un mapeo explícito te da control total:
<?php
declare(strict_types=1);
final class Producto
{
public function __construct(
public readonly int $id,
public readonly string $nombre,
public readonly string $precio, // DECIMAL: se conserva como string exacto
) {}
/** @param array{id: int|string, nombre: string, precio: string} $fila */
public static function desdeFila(array $fila): self
{
return new self((int) $fila['id'], $fila['nombre'], (string) $fila['precio']);
}
}
$stmt = $pdo->prepare('SELECT id, nombre, precio FROM productos WHERE categoria_id = ?');
$stmt->execute([4]);
$productos = array_map(Producto::desdeFila(...), $stmt->fetchAll());
La sintaxis Producto::desdeFila(...) crea un callable a partir del método y está disponible desde PHP 8.1.
Paso 5: transacciones con bloqueo de filas
Una transacción agrupa varias sentencias para que se apliquen todas o ninguna. El ejemplo clásico es mover saldo entre dos cuentas: si el débito se ejecuta y el crédito falla, el dinero desaparece. El siguiente código guarda los importes en centavos (enteros) para evitar los errores de redondeo de float:
<?php
declare(strict_types=1);
function transferir(PDO $pdo, int $origen, int $destino, int $centavos): void
{
if ($centavos <= 0 || $origen === $destino) {
throw new InvalidArgumentException('Transferencia no válida');
}
$pdo->beginTransaction();
try {
// FOR UPDATE bloquea la fila hasta el commit: nadie más la modifica a la vez
$stmt = $pdo->prepare('SELECT saldo FROM cuentas WHERE id = :id FOR UPDATE');
$stmt->execute(['id' => $origen]);
$saldo = $stmt->fetchColumn();
if ($saldo === false) {
throw new DomainException('La cuenta de origen no existe');
}
if ((int) $saldo < $centavos) {
throw new DomainException('Saldo insuficiente');
}
$pdo->prepare('UPDATE cuentas SET saldo = saldo - :monto WHERE id = :id')
->execute(['monto' => $centavos, 'id' => $origen]);
$credito = $pdo->prepare('UPDATE cuentas SET saldo = saldo + :monto WHERE id = :id');
$credito->execute(['monto' => $centavos, 'id' => $destino]);
if ($credito->rowCount() !== 1) {
throw new DomainException('La cuenta de destino no existe');
}
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack(); // deshace el débito si algo falló después
}
throw $e; // quien llama decide qué mostrar al usuario
}
}
Tres detalles que no se ven en el código pero cambian el resultado:
- El motor de tablas importa. En MySQL, las tablas MyISAM no admiten transacciones:
rollBack()no deshace nada. Usa InnoDB, que es el motor por defecto en versiones modernas. - Las sentencias de estructura cierran la transacción en MySQL. Un
CREATE TABLEo unALTER TABLEen medio provoca un commit implícito. PostgreSQL, en cambio, sí permite DDL dentro de una transacción. - En PostgreSQL, un error deja la transacción inutilizable. Tras cualquier fallo, el servidor rechaza las siguientes sentencias hasta que hagas
rollBack(). Por eso elcatchdebe deshacer siempre antes de hacer cualquier otra cosa.
Paso 6: manejo de errores que distingue casos
No todos los errores de base de datos son iguales. Un correo repetido en un registro es un error del usuario y merece un mensaje amable; una tabla inexistente es un error de programación y debe ir al log. La PDOException trae la información necesaria para separarlos en su propiedad pública errorInfo: posición 0, el código SQLSTATE estándar; posición 1, el código propio del driver; posición 2, el mensaje.
<?php
function registrarCliente(PDO $pdo, string $nombre, string $email): ?string
{
try {
$pdo->prepare('INSERT INTO clientes (nombre, email) VALUES (?, ?)')
->execute([$nombre, $email]);
return null; // sin error
} catch (PDOException $e) {
$sqlstate = $e->errorInfo[0] ?? '';
$codigoBd = (int) ($e->errorInfo[1] ?? 0);
// 23505 = clave única duplicada en PostgreSQL
// 23000 + 1062 = entrada duplicada en MySQL / MariaDB
$duplicado = $sqlstate === '23505' || ($sqlstate === '23000' && $codigoBd === 1062);
if ($duplicado) {
return 'Ya existe una cuenta con ese correo.';
}
throw $e; // cualquier otro error sigue su camino hacia el log
}
}
¿Por qué no comprobar solo 23000 en MySQL? Porque ese SQLSTATE agrupa todas las violaciones de integridad: también una clave foránea rota o un NULL en una columna obligatoria. El código 1062 es el que identifica específicamente el duplicado. Y un detalle curioso: $e->getCode() en una PDOException devuelve el SQLSTATE como string, no como entero, a diferencia de la mayoría de excepciones.
Para que este patrón funcione, la columna email debe tener un índice UNIQUE. Consultar primero si el correo existe y luego insertar deja una ventana en la que dos peticiones simultáneas pueden pasar el control; la restricción en la base de datos no tiene ese hueco.
Mensajes de error frecuentes y su causa
SQLSTATE[HY093]: Invalid parameter number
El número de valores no coincide con el de marcadores, o el array de valores posicionales tiene claves no consecutivas. Con la emulación desactivada hay una tercera causa: repetir el mismo marcador nombrado dos veces en una consulta (por ejemplo, :texto en dos condiciones). La solución es usar nombres distintos, como :texto1 y :texto2, y pasar el valor dos veces.
could not find driver
Falta la extensión del motor o está activada en un php.ini distinto al que usa tu servidor web. Ten en cuenta que la terminal y el servidor web pueden leer archivos de configuración diferentes; php --ini muestra cuál usa la línea de comandos.
rowCount() devuelve 0 en un SELECT
La documentación de PHP advierte que rowCount() no está garantizado para consultas SELECT en todos los drivers. Para saber cuántas filas obtuviste, cuenta el resultado de fetchAll() o ejecuta un SELECT COUNT(*). En UPDATE y DELETE sí es fiable, aunque MySQL, por defecto, cuenta solo las filas que realmente cambiaron, no las que cumplieron el WHERE.
¿PDO o mysqli?
Las dos extensiones admiten consultas preparadas y son seguras si se usan bien. mysqli solo trabaja con MySQL y MariaDB, y expone algunas funciones específicas de ese motor. PDO ofrece marcadores nombrados, modos de fetch más variados y la posibilidad de cambiar de motor sin reescribir toda la capa de acceso. Para proyectos nuevos que no dependen de funciones exclusivas de MySQL, PDO suele ser la elección más flexible.
Siguiente paso
Con lo que has visto ya tienes una capa de datos que no mezcla SQL con valores externos, que trata los errores según su gravedad y que no deja operaciones a medias. Eso cubre la inyección SQL, pero no los otros dos problemas que suelen aparecer en cuanto muestras esos datos en una página o aceptas formularios: el XSS y el CSRF. Los tratamos con ejemplos de código vulnerable y su corrección en seguridad básica en PHP: inyección SQL, XSS y CSRF.

1 Comment