Saltar al contenido
pdo.php · devschool

Conectar PHP con MySQL usando PDO

Lección 21 de 26 · 17 min de lectura · Actualizado el

En esta lección
  1. Qué es PDO
  2. La base de datos de ejemplo
  3. Conectar: DSN, usuario y opciones
  4. Consultas sin datos externos: query()
  5. Consultas preparadas
  6. Recoger los resultados: fetch
  7. Insertar, actualizar y borrar
  8. Casos especiales: LIKE, LIMIT e IN
  9. Transacciones
  10. Gestionar los errores
  11. Practicar sin MySQL: SQLite en memoria
  12. Errores frecuentes
  13. 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() o mysql_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 ;:

ParteSignificado
mysql:Driver que se usa
host=localhostServidor de MySQL (en un hosting te dirán cuál)
port=3306Puerto; 3306 es el de MySQL y puedes omitirlo
dbname=senderismoBase de datos
charset=utf8mb4Codificació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 devolver false en 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 root sin 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étodoDevuelveÚsalo para
fetch()La siguiente fila, o false si no quedanBuscar un registro por id
fetchAll()Un array con todas las filasListados
fetchColumn()El valor de la primera columna de la siguiente filaCOUNT(*), 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
ModoCada fila llega como…
PDO::FETCH_ASSOCArray asociativo: $fila['nombre']
PDO::FETCH_OBJObjeto genérico: $fila->nombre
PDO::FETCH_CLASSObjeto de tu clase
PDO::FETCH_COLUMNSolo el valor de una columna
PDO::FETCH_KEY_PAIRPar primera columna => segunda columna
PDO::FETCH_BOTHArray con claves de nombre y de número (el defecto si no configuras nada)

Nota: con MySQL, las columnas DECIMAL llegan como cadena ("12.0"), porque un float de 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(): el id generado por el último INSERT (como cadena).
  • $consulta->rowCount(): cuántas filas ha afectado un UPDATE o un DELETE.
<?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 UPDATE deja 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 un SELECT.

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 CHECK se 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ónQué 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 ordenadorMira el mensaje completo para depurar
En produccióndisplay_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 siempre prepare() con marcadores.
  • Comillas alrededor del marcador: WHERE zona = '?' busca el texto ? literal. El marcador va sin comillas.
  • Olvidar charset=utf8mb4 en el DSN: las tildes salen como á o ?.
  • No comprobar el false de fetch(): si el registro no existe, $ruta['nombre'] da un aviso. Comprueba if ($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 con error_log().
  • Credenciales dentro de la carpeta pública o en Git: guárdalas en un archivo de configuración fuera de public_html.

Resumen

TareaCódigo
Conectarnew PDO('mysql:host=localhost;dbname=bd;charset=utf8mb4', $usuario, $clave, $opciones)
Opciones recomendadasERRMODE_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 valorfetch() / fetchAll() / fetchColumn()
Id del último INSERT$pdo->lastInsertId()
Filas afectadas$c->rowCount()
LIMIT segurobindValue('limite', $n, PDO::PARAM_INT)
TransacciónbeginTransaction(), commit(), rollBack()
Error de dato repetidoPDOException 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

[PHP] ¿Qué problema evitan las consultas preparadas con parámetros (? o :nombre)?

[PHP] ¿Qué hay mal en esta consulta preparada?
$q = $pdo->prepare("SELECT * FROM rutas WHERE zona = '?'");
$q->execute(['Gredos']);

[PHP] Buscas un id que no existe con $consulta->fetch(). ¿Qué devuelve?

[PHP] ¿Cuántas filas hay en la tabla al final?
$pdo->exec('INSERT INTO t VALUES (1), (2)');

$pdo->beginTransaction();
$pdo->exec('INSERT INTO t VALUES (3)');
$pdo->rollBack();

echo $pdo->query('SELECT COUNT(*) FROM t')->fetchColumn();

¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.