Conexión a una base de datos en PHP con PDO y consultas preparadas

Como desarrollador web, a menudo necesitas interactuar con una base de datos para guardar y recuperar información. Con PHP puedes establecer sin dificultad una conexión a una base de datos y hacer ese trabajo. En este artículo vamos a repasar los pasos para establecer una conexión a una base de

Conexión a una base de datos en PHP con PDO y consultas preparadas
Respuesta rápida

Usa PDO con charset=utf8mb4 en el DSN, ATTR_ERRMODE en excepción y ATTR_EMULATE_PREPARES a false. Desde PHP 8.0 los errores de PDO ya lanzan una excepción por defecto, y desde PHP 8.1 mysqli también: el patrón if (!$conn) die(...) no se dispara nunca. Todo valor que venga del usuario pasa por una consulta preparada.

Conectarse a una base de datos es lo primero que hace casi cualquier aplicación PHP. La extensión que elijas y la forma de escribir la conexión deciden dos cosas: si los errores se entienden el día que algo se rompe, y la resistencia a las inyecciones SQL. Este artículo muestra la conexión tal y como se escribe en 2026, sobre PHP 8.5, con las trampas que han cambiado desde PHP 8.0.

PDO, la conexión que debes elegir

PDO es la interfaz genérica de PHP para las bases de datos. Habla con MySQL y MariaDB, pero también con PostgreSQL, SQLite o SQL Server, con el mismo código de aplicación. Es la opción por defecto, salvo que tengas una razón concreta para hacer otra cosa.

src/Base.php
<?php

declare(strict_types=1);

$dsn = 'mysql:host=127.0.0.1;port=3306;dbname=ma_base;charset=utf8mb4';

$pdo = new PDO($dsn, 'utilisateur', 'mot_de_passe', [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    PDO::ATTR_EMULATE_PREPARES   => false,
]);

En este bloque cuentan tres detalles, y son justo los que se olvidan.

  • charset=utf8mb4 en el DSN. Sin él, la conexión usa el juego de caracteres por defecto del servidor. Los emoji y parte de los caracteres asiáticos se convierten en signos de interrogación, y la codificación pasa a ser un vector de inyección con ciertos juegos de caracteres. El charset se declara en el DSN, no con una consulta SET NAMES.
  • ATTR_EMULATE_PREPARES a false. Por defecto, el driver de MySQL simula las consultas preparadas del lado de PHP en lugar de enviarlas al servidor. Al desactivarlo, es el servidor el que separa la consulta de los datos, y los tipos enteros siguen siendo enteros a la vuelta.
  • ATTR_ERRMODE explícito. Desde PHP 8.0, ERRMODE_EXCEPTION ya es el valor por defecto. Dejarlo escrito no cambia el comportamiento, pero le dice a quien lee el código que los errores salen como excepciones.

Comprobación en la máquina de pruebas:

php
$pdo2 = new PDO($dsn, $utilisateur, $motDePasse); // sin opciones
var_dump($pdo2->getAttribute(PDO::ATTR_ERRMODE) === PDO::ERRMODE_EXCEPTION);
// bool(true) en PHP 8.5.10

La contraseña no se escribe en el archivo

Las credenciales no pintan nada en un archivo versionado. La forma más simple es leerlas del entorno, con un mensaje claro si falta la variable.

src/connexion.php
<?php

declare(strict_types=1);

function connexion(): PDO
{
    static $pdo = null;
    if ($pdo instanceof PDO) {
        return $pdo;
    }

    $hote = getenv('DB_HOST') ?: '127.0.0.1';
    $base = getenv('DB_NAME') ?: throw new RuntimeException('DB_NAME manquant');
    $user = getenv('DB_USER') ?: throw new RuntimeException('DB_USER manquant');
    $pass = getenv('DB_PASS') ?: '';

    return $pdo = new PDO(
        "mysql:host={$hote};dbname={$base};charset=utf8mb4",
        $user,
        $pass,
        [
            PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_EMULATE_PREPARES   => false,
        ],
    );
}

El operador ?: seguido de throw funciona desde PHP 8.0, cuando throw pasó a ser una expresión. La conexión se guarda en una variable estática: abrir una sola conexión por petición es más que suficiente.

Nunca dejes escapar el mensaje de error

Una excepción de conexión contiene el host, el usuario y, a veces, la contraseña dentro de la traza. Mostrada tal cual en una página pública, le regala medio trabajo a un atacante.

php
try {
    $pdo = connexion();
} catch (PDOException $e) {
    // El detalle va al registro, nunca a la respuesta HTTP
    error_log('Connexion base impossible : ' . $e->getMessage());
    http_response_code(503);
    exit('Service momentanément indisponible.');
}

Desde PHP 8.2, los parámetros marcados con #[SensitiveParameter] aparecen como Object(SensitiveParameterValue) en las trazas. Es una protección útil, pero solo cubre la traza: el mensaje sigue siendo igual de hablador.

Conectarse es solo la mitad del trabajo

Una conexión sin consultas preparadas no protege de nada. La regla cabe en una frase: los datos nunca se concatenan dentro de la consulta.

php
// No hagas esto: el valor entra directamente en la consulta
$sql = "SELECT * FROM membres WHERE email = '" . $_GET['email'] . "'";

// Consulta preparada: el valor viaja aparte
$requete = $pdo->prepare('SELECT id, email, cree_le FROM membres WHERE email = ?');
$requete->execute([$_GET['email'] ?? '']);
$membre = $requete->fetch();

Los marcadores con nombre se leen mejor en cuanto hay más de dos valores:

php
$requete = $pdo->prepare(
    'SELECT id, titre FROM articles
     WHERE statut = :statut AND publie_le >= :depuis
     ORDER BY publie_le DESC LIMIT :limite'
);
$requete->bindValue(':statut', 'publie');
$requete->bindValue(':depuis', '2026-01-01');
$requete->bindValue(':limite', 10, PDO::PARAM_INT); // tipar siempre el LIMIT
$requete->execute();

foreach ($requete as $ligne) {
    echo $ligne['titre'], "\n";
}

El PDO::PARAM_INT del LIMIT no es decorativo, y su efecto depende de un ajuste que se olvida a menudo. Esto es lo que da bindValue(':limite', 3) sin tipar, según si la emulación de consultas preparadas está activa o no:

Servidor EMULATE_PREPARES Sin PARAM_INT Con PARAM_INT
MariaDB 11.8.9 false aceptado aceptado
MariaDB 11.8.9 true (por defecto) error SQL 1064 aceptado
MySQL 8.4.11 false aceptado aceptado
MySQL 8.4.11 true (por defecto) error SQL 1064 aceptado

Con la emulación, PDO construye la consulta él mismo y rodea el valor de comillas: LIMIT '3', que el servidor rechaza. Es la configuración por defecto, así que es el caso con el que se topa la mayoría. Tipar el valor funciona en las cuatro situaciones: es la única forma que hay que recordar.

Un marcador nunca sustituye a un nombre de tabla o de columna. Si el nombre de columna viene del usuario, se valida contra una lista cerrada:

php
$colonnesTriables = ['titre', 'publie_le', 'vues'];
$tri = in_array($_GET['tri'] ?? '', $colonnesTriables, true) ? $_GET['tri'] : 'publie_le';
$sql = "SELECT id, titre FROM articles ORDER BY {$tri} DESC";

¿Y mysqli?

mysqli sigue siendo válido si el proyecto nunca va a hablar con nada que no sea MySQL o MariaDB. Su comportamiento ha cambiado en un punto importante: desde PHP 8.1, mysqli_report() está ajustado a MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT, y los errores lanzan una excepción en lugar de devolver false.

php
<?php

declare(strict_types=1);

try {
    $mysqli = new mysqli('127.0.0.1', $utilisateur, $motDePasse, 'ma_base');
    $mysqli->set_charset('utf8mb4');
} catch (mysqli_sql_exception $e) {
    error_log('Connexion mysqli impossible : ' . $e->getMessage());
    http_response_code(503);
    exit('Service momentanément indisponible.');
}

$requete = $mysqli->prepare('SELECT id, email FROM membres WHERE email = ?');
$requete->bind_param('s', $email);
$requete->execute();
$membre = $requete->get_result()->fetch_assoc();

La prueba de conexión que ya no prueba nada

El patrón siguiente arrastra por muchísimos tutoriales, incluidas las versiones anteriores de este. Ya no funciona desde PHP 8.1.

php
$conn = mysqli_connect($hote, $utilisateur, $motDePasse, $base);

if (!$conn) {
    die('La connexion a échoué : ' . mysqli_connect_error());
}

Ejecutado en PHP 8.5.10 con una contraseña incorrecta, este código no produce el mensaje previsto. Produce esto:

code
PHP Fatal error:  Uncaught mysqli_sql_exception: Access denied for user
'gekkode'@'172.27.0.10' (using password: YES) in /app/test.php:8

La línea if (!$conn) no se alcanza nunca: mysqli_connect() ha lanzado la excepción antes. El die() es código muerto, y el error en bruto sale hacia el navegador si la visualización de errores está activa. La reescritura con try / catch de más arriba es la única forma correcta hoy.

Las funciones mysql_* ya no existen

Los mysql_connect(), mysql_query() y mysql_real_escape_string() que todavía se cruzan en scripts antiguos se retiraron en PHP 7.0, publicado en 2015. En PHP 8.5:

php
var_dump(function_exists('mysql_connect'));           // bool(false)
var_dump(function_exists('mysql_real_escape_string')); // bool(false)

No hay compatibilidad que mantener, solo una migración que hacer, preferiblemente hacia PDO.

Para ir más lejos

Para insertar un gran volumen de filas, la conexión es solo la mitad del asunto: consulta leer archivos grandes con PHP, que muestra la inserción por lotes dentro de una transacción. Para redirigir después de un tratamiento, consulta crear una redirección en PHP. Una vez montada la conexión, lo siguiente es no rehacerla en cada archivo. Los demás tutoriales de PHP del hub de Desarrollo web cubren las piezas vecinas: instalar Composer para organizar el proyecto, y las bases de la POO en PHP para guardar esta conexión en una clase en lugar de en una función global.

Errores frecuentes

El die() que no se ejecuta nunca Desde PHP 8.1, mysqli_connect() lanza una mysqli_sql_exception antes de llegar a if (!$conn). El mensaje de error en bruto sale hacia el navegador en lugar del mensaje previsto.
Charset ausente del DSN Sin charset=utf8mb4, los emoji y ciertos caracteres se convierten en signos de interrogación, y la codificación puede servir de vector de inyección.
LIMIT sin PDO::PARAM_INT Con la emulación de consultas preparadas, activa por defecto, PDO escribe LIMIT '3' y el servidor devuelve un error SQL 1064. Comprobado en MariaDB 11.8.9 y MySQL 8.4.11.
Mensaje de excepción mostrado La traza de una PDOException contiene el host y el usuario. Va al registro, nunca a la respuesta HTTP.
Marcador para un nombre de columna Un marcador solo sustituye a un valor. Un nombre de tabla o de columna se valida contra una lista cerrada.

MySQLPDOPHPSécurité

Damien Flandrin Desarrollador web desde 2010, creador de Gekkode y de Email Impact. Cada artículo se prueba en un proyecto real antes de publicarse. Contacto
Newsletter

Las nuevas pruebas, tutoriales y proyectos, por correo.

Pruebas reproducibles, código versionado, resultados fechados. Nunca spam.