Conectar PHP con MySQL usando PDO
En esta lección
- Qué es PDO
- La base de datos de ejemplo
- Conectar: DSN, usuario y opciones
- Consultas sin datos externos: query()
- Consultas preparadas
- Recoger los resultados: fetch
- Insertar, actualizar y borrar
- Casos especiales: LIKE, LIMIT e IN
- Transacciones
- Gestionar los errores
- Practicar sin MySQL: SQLite en memoria
- Errores frecuentes
- Resumen
Hasta ahora tus datos vivían en arrays, archivos de texto o JSON. Eso vale para empezar, pero en cuanto una web tiene usuarios, reservas o un catálogo, necesita una base de datos. En esta lección aprenderás a conectar PHP con MySQL usando PDO, a lanzar consultas de forma segura con consultas preparadas, a recoger los resultados en el formato que más te convenga y a gestionar los errores sin enseñar información delicada.
Se da por hecho que sabes lo básico de SQL: SELECT, WHERE e INSERT, UPDATE y DELETE.
Qué es PDO
PDO (PHP Data Objects) es la capa de PHP para hablar con bases de datos. Funciona con MySQL, MariaDB, PostgreSQL, SQLite y otras con el mismo código: lo único que cambia es la cadena de conexión. Para usarla necesitas que PHP tenga el driver de tu base de datos (pdo_mysql para MySQL), que XAMPP, Laragon y casi cualquier hosting traen activado.
Hay otra opción, la extensión mysqli, que también es moderna y segura, pero solo sirve para MySQL y su API es más incómoda. En este curso usamos PDO.
Cuidado: verás en tutoriales antiguos funciones como
mysql_connect()omysql_query(). Se eliminaron en PHP 7 (2015) y no funcionan en ninguna versión actual. Si un ejemplo las usa, búscate otro.
La base de datos de ejemplo
Vamos a trabajar con las rutas de un club de senderismo. Créala desde phpMyAdmin o desde la consola de MySQL (si necesitas repasar, mira CREATE TABLE):
CREATE DATABASE senderismo CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE senderismo;
CREATE TABLE rutas (
id INT AUTO_INCREMENT PRIMARY KEY,
nombre VARCHAR(100) NOT NULL UNIQUE,
zona VARCHAR(50) NOT NULL,
km DECIMAL(5,1) NOT NULL,
desnivel INT NOT NULL,
dificultad VARCHAR(10) NOT NULL
);
INSERT INTO rutas (nombre, zona, km, desnivel, dificultad) VALUES
('Ruta del Cares', 'Picos de Europa', 12.0, 450, 'media'),
('Lagos de Saliencia', 'Somiedo', 9.5, 520, 'media'),
('Faro del Caballo', 'Santoña', 5.0, 180, 'facil'),
('Pico Almanzor', 'Gredos', 16.0, 1250, 'dificil');
Conectar: DSN, usuario y opciones
Para conectar creas un objeto PDO con tres datos y, opcionalmente, un array de opciones:
<?php
$dsn = 'mysql:host=localhost;port=3306;dbname=senderismo;charset=utf8mb4';
$usuario = 'senderismo_app';
$clave = 'Cambia-esta-clave-2026';
$opciones = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
$pdo = new PDO($dsn, $usuario, $clave, $opciones);
El DSN (Data Source Name) es una cadena que dice a qué base de datos conectarte. Empieza por el nombre del driver y dos puntos, y sigue con pares clave=valor separados por ;:
| Parte | Significado |
|---|---|
mysql: | Driver que se usa |
host=localhost | Servidor de MySQL (en un hosting te dirán cuál) |
port=3306 | Puerto; 3306 es el de MySQL y puedes omitirlo |
dbname=senderismo | Base de datos |
charset=utf8mb4 | Codificación de la conexión. Sin ella, las tildes y los emojis pueden llegar rotos |
Y las tres opciones, que conviene poner siempre:
ATTR_ERRMODE => ERRMODE_EXCEPTION: si una consulta falla, PDO lanza una excepción en lugar de devolverfalseen silencio. Es el valor por defecto desde PHP 8, pero escribirlo deja clara la intención.ATTR_DEFAULT_FETCH_MODE => FETCH_ASSOC: cada fila llega como un array asociativo ($fila['nombre']). Sin esto, cada valor aparece dos veces, con su nombre y con su número.ATTR_EMULATE_PREPARES => false: las consultas preparadas las hace de verdad el servidor MySQL, y los números llegan como números.
Nota: en XAMPP el usuario por defecto es
rootsin contraseña. Para practicar en tu ordenador vale, pero en un servidor real crea un usuario solo para tu aplicación, con permisos únicamente sobre su base de datos. Lo tienes explicado en Usuarios y permisos.
Un archivo de conexión reutilizable
Todas las páginas de tu web necesitan la conexión, así que se escribe una vez en un archivo y se incluye con require (lo viste en include y require). Las credenciales van en un archivo aparte, fuera de la carpeta pública si tu hosting lo permite, para que nunca se puedan descargar ni acaben en Git:
<?php
// config.php (fuera de public_html)
return [
'dsn' => 'mysql:host=localhost;dbname=senderismo;charset=utf8mb4',
'usuario' => 'senderismo_app',
'clave' => 'Cambia-esta-clave-2026',
];
<?php
// conexion.php
$config = require __DIR__ . '/../config.php';
try {
$pdo = new PDO($config['dsn'], $config['usuario'], $config['clave'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
} catch (PDOException $e) {
error_log('Error de conexión: ' . $e->getMessage());
http_response_code(500);
exit('Ahora mismo no podemos acceder a los datos. Inténtalo más tarde.');
}
Fíjate en dos cosas. Un archivo PHP puede hacer return de un valor, y require te lo devuelve: es una forma limpia de guardar configuración. Y si la conexión falla, el mensaje técnico va al registro de errores con error_log(), no a la pantalla: ese mensaje puede incluir el nombre del servidor o del usuario, y no quieres regalárselo a nadie.
A partir de aquí, cualquier página empieza con require __DIR__ . '/conexion.php'; y ya tiene $pdo.
Consultas sin datos externos: query()
Si la consulta está escrita entera por ti y no lleva ningún dato que venga de fuera, puedes lanzarla directamente con query(). Devuelve un objeto PDOStatement que puedes recorrer con foreach, una fila en cada vuelta:
<?php
$resultado = $pdo->query('SELECT nombre, desnivel FROM rutas ORDER BY desnivel');
foreach ($resultado as $fila) {
echo "{$fila['nombre']}: {$fila['desnivel']} m de desnivel\n";
}
// Faro del Caballo: 180 m de desnivel
// Ruta del Cares: 450 m de desnivel
// Lagos de Saliencia: 520 m de desnivel
// Pico Almanzor: 1250 m de desnivel
Para sentencias que no devuelven filas (crear una tabla, un DELETE fijo), existe exec(), que devuelve el número de filas afectadas.
Consultas preparadas
En cuanto la consulta lleva un dato que no has escrito tú (lo que busca el usuario, el id de la URL, los campos de un formulario), nunca lo metas en la cadena SQL. Usa una consulta preparada: escribes la consulta con marcadores en el lugar de los datos y le pasas los valores por separado.
Se hace en dos pasos: prepare() envía la consulta con huecos al servidor, y execute() le manda los valores.
<?php
$consulta = $pdo->prepare('SELECT nombre, zona FROM rutas WHERE dificultad = ? AND km <= ?');
$consulta->execute(['media', 10]);
print_r($consulta->fetchAll());
// Array
// (
// [0] => Array
// (
// [nombre] => Lagos de Saliencia
// [zona] => Somiedo
// )
//
// )
¿Por qué tanto empeño? Porque el servidor recibe la estructura de la consulta y los datos por separado. Un valor nunca se interpreta como código SQL, por raro que sea. Si concatenaras el texto del usuario, alguien podría escribir trozos de SQL y cambiar lo que hace tu consulta: es la inyección SQL, que verás con un ejemplo real en Seguridad.
Marcadores con nombre
Con ? tienes que pasar los valores en el mismo orden. Cuando hay varios, se lee mejor con marcadores con nombre, que empiezan por dos puntos. Los valores se pasan en un array asociativo, sin los dos puntos:
<?php
$consulta = $pdo->prepare('SELECT nombre FROM rutas WHERE zona = :zona');
$consulta->execute(['zona' => 'Gredos']);
echo $consulta->fetchColumn(); // Pico Almanzor
No mezcles ? y :nombre en la misma consulta: PDO da error.
Reutilizar una consulta preparada
Una consulta preparada puede ejecutarse muchas veces con valores distintos. Es lo natural para insertar varias filas:
<?php
$insertar = $pdo->prepare(
'INSERT INTO rutas (nombre, zona, km, desnivel, dificultad) VALUES (?, ?, ?, ?, ?)'
);
$nuevas = [
['Garganta del Cares', 'Picos de Europa', 11.0, 400, 'media'],
['Senda del Oso', 'Asturias', 20.0, 150, 'facil'],
];
foreach ($nuevas as $ruta) {
$insertar->execute($ruta);
}
echo $pdo->query('SELECT COUNT(*) FROM rutas')->fetchColumn(); // 6
Recoger los resultados: fetch
Después de execute(), los resultados se leen con uno de estos métodos:
| Método | Devuelve | Úsalo para |
|---|---|---|
fetch() | La siguiente fila, o false si no quedan | Buscar un registro por id |
fetchAll() | Un array con todas las filas | Listados |
fetchColumn() | El valor de la primera columna de la siguiente fila | COUNT(*), un solo dato |
El caso típico de fetch() es cargar un registro concreto. Si no existe, devuelve false, así que compruébalo siempre:
<?php
$consulta = $pdo->prepare('SELECT * FROM rutas WHERE id = ?');
$consulta->execute([3]);
$ruta = $consulta->fetch();
if ($ruta === false) {
echo 'Esa ruta no existe';
} else {
echo "{$ruta['nombre']} ({$ruta['zona']})";
}
// Faro del Caballo (Santoña)
Y fetchColumn() es perfecto para contar:
<?php
$total = $pdo->query('SELECT COUNT(*) FROM rutas')->fetchColumn();
var_dump($total); // int(6)
Modos de fetch
El modo de fetch decide la forma en que llega cada fila. Ya has puesto FETCH_ASSOC por defecto, pero puedes pedir otro en cada llamada:
<?php
// Cada fila como objeto: $ruta->nombre
$ruta = $pdo->query('SELECT nombre, desnivel FROM rutas WHERE id = 1')->fetch(PDO::FETCH_OBJ);
echo "$ruta->nombre sube $ruta->desnivel m\n";
// Ruta del Cares sube 450 m
// Una sola columna como array simple
$nombres = $pdo->query("SELECT nombre FROM rutas WHERE zona = 'Picos de Europa' ORDER BY nombre")
->fetchAll(PDO::FETCH_COLUMN);
echo implode(' | ', $nombres), "\n";
// Garganta del Cares | Ruta del Cares
// Dos columnas como clave => valor: ideal para un <select>
$zonas = $pdo->query('SELECT id, zona FROM rutas WHERE id <= 3')->fetchAll(PDO::FETCH_KEY_PAIR);
print_r($zonas);
// Array
// (
// [1] => Picos de Europa
// [2] => Somiedo
// [3] => Santoña
// )
También puedes llenar directamente objetos de una clase tuya con FETCH_CLASS. PDO crea un objeto por fila y rellena las propiedades que se llamen como las columnas (repasa Clases y objetos si lo necesitas):
<?php
class Ruta
{
public int $id;
public string $nombre;
public int $desnivel;
public function esExigente(): bool
{
return $this->desnivel > 1000;
}
}
$rutas = $pdo->query('SELECT id, nombre, desnivel FROM rutas ORDER BY desnivel DESC')
->fetchAll(PDO::FETCH_CLASS, Ruta::class);
echo $rutas[0]->nombre, ': ', $rutas[0]->esExigente() ? 'exigente' : 'asequible';
// Pico Almanzor: exigente
| Modo | Cada fila llega como… |
|---|---|
PDO::FETCH_ASSOC | Array asociativo: $fila['nombre'] |
PDO::FETCH_OBJ | Objeto genérico: $fila->nombre |
PDO::FETCH_CLASS | Objeto de tu clase |
PDO::FETCH_COLUMN | Solo el valor de una columna |
PDO::FETCH_KEY_PAIR | Par primera columna => segunda columna |
PDO::FETCH_BOTH | Array con claves de nombre y de número (el defecto si no configuras nada) |
Nota: con MySQL, las columnas
DECIMALllegan como cadena ("12.0"), porque unfloatde PHP podría perder precisión. Si vas a hacer cálculos, conviértelas con(float).
Insertar, actualizar y borrar
Para cambiar datos se usa prepare() y execute() igual que para leer. Dos métodos te dicen qué ha pasado:
$pdo->lastInsertId(): elidgenerado por el últimoINSERT(como cadena).$consulta->rowCount(): cuántas filas ha afectado unUPDATEo unDELETE.
<?php
$insertar = $pdo->prepare(
'INSERT INTO rutas (nombre, zona, km, desnivel, dificultad)
VALUES (:nombre, :zona, :km, :desnivel, :dificultad)'
);
$insertar->execute([
'nombre' => 'Mirador de Ordiales',
'zona' => 'Picos de Europa',
'km' => 14.5,
'desnivel' => 600,
'dificultad' => 'media',
]);
echo 'Ruta creada con id ', $pdo->lastInsertId(), "\n";
// Ruta creada con id 7
$actualizar = $pdo->prepare('UPDATE rutas SET dificultad = ? WHERE zona = ?');
$actualizar->execute(['dificil', 'Picos de Europa']);
echo $actualizar->rowCount(), " rutas actualizadas\n";
// 3 rutas actualizadas
$borrar = $pdo->prepare('DELETE FROM rutas WHERE id = ?');
$borrar->execute([42]);
echo $borrar->rowCount(), " rutas borradas\n";
// 0 rutas borradas
Que rowCount() dé 0 no es un error: simplemente ninguna fila cumplía la condición. Sirve para avisar de “esa ruta ya no existe”. En la próxima lección usarás todo esto desde formularios en un CRUD completo.
Cuidado: en MySQL, si un
UPDATEdeja una fila con los mismos valores que ya tenía,rowCount()no la cuenta. Por eso no lo uses para saber si una fila existe; para eso, haz unSELECT.
Casos especiales: LIKE, LIMIT e IN
LIKE. Los comodines % van en el valor, no en la consulta:
<?php
$texto = 'lago';
$consulta = $pdo->prepare('SELECT nombre FROM rutas WHERE nombre LIKE ?');
$consulta->execute(['%' . $texto . '%']);
print_r($consulta->fetchAll(PDO::FETCH_COLUMN));
// Array
// (
// [0] => Lagos de Saliencia
// )
LIMIT y OFFSET. Para paginar, indica que el valor es un entero con bindValue() y PDO::PARAM_INT, y llama a execute() sin argumentos. Así funciona aunque cambies la opción de emular consultas preparadas:
<?php
$porPagina = 2;
$pagina = 2;
$consulta = $pdo->prepare('SELECT nombre FROM rutas ORDER BY id LIMIT :limite OFFSET :desde');
$consulta->bindValue('limite', $porPagina, PDO::PARAM_INT);
$consulta->bindValue('desde', ($pagina - 1) * $porPagina, PDO::PARAM_INT);
$consulta->execute();
print_r($consulta->fetchAll(PDO::FETCH_COLUMN));
// Array
// (
// [0] => Faro del Caballo
// [1] => Pico Almanzor
// )
IN con una lista. Un marcador vale por un solo valor, así que IN (?) con un array no funciona. Genera tantos ? como elementos tengas:
<?php
$ids = [1, 3, 4];
$marcas = implode(',', array_fill(0, count($ids), '?')); // "?,?,?"
$consulta = $pdo->prepare("SELECT nombre FROM rutas WHERE id IN ($marcas) ORDER BY id");
$consulta->execute($ids);
echo implode(', ', $consulta->fetchAll(PDO::FETCH_COLUMN));
// Ruta del Cares, Faro del Caballo, Pico Almanzor
Aquí sí metes algo en la cadena SQL, pero son solo signos ? que has generado tú; los valores siguen yendo aparte.
Transacciones
Cuando varias operaciones deben hacerse todas o ninguna, usa una transacción (la idea la tienes en Transacciones). En PDO son tres métodos: beginTransaction(), commit() y rollBack().
Imagina que un socio regala entradas de su bono a otro. Restar a uno y sumar al otro tiene que ir junto:
<?php
// Tabla bonos: socio VARCHAR(20) PRIMARY KEY, entradas INT NOT NULL CHECK (entradas >= 0)
function traspasarEntradas(PDO $pdo, string $de, string $a, int $cantidad): bool
{
try {
$pdo->beginTransaction();
$restar = $pdo->prepare('UPDATE bonos SET entradas = entradas - ? WHERE socio = ?');
$restar->execute([$cantidad, $de]);
$sumar = $pdo->prepare('UPDATE bonos SET entradas = entradas + ? WHERE socio = ?');
$sumar->execute([$cantidad, $a]);
$pdo->commit();
return true;
} catch (PDOException $e) {
$pdo->rollBack();
error_log($e->getMessage());
return false;
}
}
// ana tiene 3 entradas y luis, 0
var_dump(traspasarEntradas($pdo, 'ana', 'luis', 2)); // bool(true)
var_dump(traspasarEntradas($pdo, 'ana', 'luis', 5)); // bool(false): ana se quedaría en -4
En el segundo traspaso, la resta incumple el CHECK y lanza una excepción; rollBack() deshace todo lo hecho desde beginTransaction(). Al final, ana tiene 1 entrada y luis 2, sin entradas perdidas ni inventadas.
Nota: las transacciones solo funcionan con tablas InnoDB, el motor por defecto de MySQL desde hace años. Los
CHECKse respetan desde MySQL 8.0.16.
Gestionar los errores
Con el modo de excepciones, cualquier fallo lanza un PDOException. Puedes capturarlo cuando sepas qué hacer con él. El caso más útil es el de un valor repetido en una columna UNIQUE: su código SQLSTATE es '23000' (una cadena, no un número):
<?php
$insertar = $pdo->prepare('INSERT INTO rutas (nombre, zona, km, desnivel, dificultad) VALUES (?, ?, ?, ?, ?)');
try {
$insertar->execute(['Senda del Oso', 'Asturias', 20.0, 150, 'facil']);
} catch (PDOException $e) {
if ($e->getCode() === '23000') {
echo 'Ya existe una ruta con ese nombre.';
} else {
throw $e; // otro problema: que lo gestione quien corresponda
}
}
// Ya existe una ruta con ese nombre.
$e->getMessage() trae el detalle técnico (en MySQL, el error 1062 Duplicate entry). Úsalo para el registro con error_log(), nunca para mostrárselo al visitante.
| Situación | Qué hacer |
|---|---|
| Error que sabes resolver (dato repetido) | Captúralo y muestra un mensaje claro al usuario |
| Error inesperado (tabla que no existe, servidor caído) | Déjalo subir; regístralo y muestra una página de error genérica |
| En tu ordenador | Mira el mensaje completo para depurar |
| En producción | display_errors = Off y log_errors = On en php.ini |
Practicar sin MySQL: SQLite en memoria
Como PDO usa el mismo código para todas las bases de datos, puedes probar consultas sin instalar nada con SQLite, que guarda la base de datos en un archivo o, como aquí, solo en memoria:
<?php
$pdo = new PDO('sqlite::memory:');
$pdo->exec('CREATE TABLE notas (alumno TEXT, nota INTEGER)');
$insertar = $pdo->prepare('INSERT INTO notas VALUES (?, ?)');
foreach ([['Iker', 7], ['Noa', 9], ['Leire', 6]] as $fila) {
$insertar->execute($fila);
}
echo $pdo->query('SELECT AVG(nota) FROM notas')->fetchColumn(); // 7.3333333333333
Es muy práctico para hacer pruebas desde la terminal con php prueba.php. Lo único que cambia respecto a MySQL son algunos detalles de SQL (tipos de datos, AUTOINCREMENT), no la forma de usar PDO.
Errores frecuentes
- Concatenar datos del usuario en el SQL:
"... WHERE id = $id"abre la puerta a la inyección SQL. Usa siempreprepare()con marcadores. - Comillas alrededor del marcador:
WHERE zona = '?'busca el texto?literal. El marcador va sin comillas. - Olvidar
charset=utf8mb4en el DSN: las tildes salen comoáo?. - No comprobar el
falsedefetch(): si el registro no existe,$ruta['nombre']da un aviso. Compruebaif ($ruta === false). - Pasar un array a un solo marcador en
IN (?): genera un?por elemento. - Enseñar
$e->getMessage()al visitante: revela detalles de tu servidor. Regístralo conerror_log(). - Credenciales dentro de la carpeta pública o en Git: guárdalas en un archivo de configuración fuera de
public_html.
Resumen
| Tarea | Código |
|---|---|
| Conectar | new PDO('mysql:host=localhost;dbname=bd;charset=utf8mb4', $usuario, $clave, $opciones) |
| Opciones recomendadas | ERRMODE_EXCEPTION, FETCH_ASSOC, EMULATE_PREPARES => false |
| Consulta fija | $pdo->query('SELECT ...') |
| Consulta con datos | $c = $pdo->prepare('... WHERE id = ?'); $c->execute([$id]); |
| Marcadores con nombre | :zona y execute(['zona' => 'Gredos']) |
| Una fila / todas / un valor | fetch() / fetchAll() / fetchColumn() |
| Id del último INSERT | $pdo->lastInsertId() |
| Filas afectadas | $c->rowCount() |
| LIMIT seguro | bindValue('limite', $n, PDO::PARAM_INT) |
| Transacción | beginTransaction(), commit(), rollBack() |
| Error de dato repetido | PDOException con getCode() === '23000' |
Ya sabes hablar con la base de datos. En la siguiente lección lo juntas con formularios para crear, listar, editar y borrar registros: CRUD con PDO.
Pon a prueba lo que has aprendido
¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.