Połączenie z bazą danych w PHP: PDO, DSN i zapytania przygotowane

Większość aplikacji w PHP zaczyna się od połączenia z bazą danych. Ten poradnik pokazuje, jak napisać je na PHP 8.5 z PDO: DSN z utf8mb4, zapytania przygotowane, obsługa wyjątków i to, co zmieniło się w mysqli od PHP 8.1.

Połączenie z bazą danych w PHP: PDO, DSN i zapytania przygotowane
Szybka odpowiedź

Użyj PDO z charset=utf8mb4 w DSN, ATTR_ERRMODE ustawionym na wyjątki i ATTR_EMULATE_PREPARES na false. Od PHP 8.0 błędy PDO i tak domyślnie rzucają wyjątek, a od PHP 8.1 robi to również mysqli: wzorzec if (!$conn) die(...) nie zadziała już nigdy. Każda wartość pochodząca od użytkownika idzie przez zapytanie przygotowane.

Połączenie z bazą danych to pierwsza rzecz, jaką robi większość aplikacji w PHP. Wybór rozszerzenia i sposób zapisania połączenia przesądzają o dwóch sprawach: o czytelności błędów w dniu, w którym coś się posypie, i o odporności na wstrzyknięcia SQL. Ten artykuł pokazuje połączenie tak, jak pisze się je w 2026 roku, na PHP 8.5, razem z pułapkami, które zmieniły się od PHP 8.0.

PDO, czyli połączenie do zapamiętania

PDO to ogólny interfejs PHP do baz danych. Rozmawia z MySQL-em i MariaDB, ale też z PostgreSQL-em, SQLite i SQL Serverem, przy tym samym kodzie aplikacji. To wybór domyślny, chyba że masz konkretny powód, żeby zrobić inaczej.

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,
]);

W tym bloku liczą się trzy szczegóły, dokładnie te, o których się zapomina.

  • charset=utf8mb4 w DSN. Bez tego połączenie używa domyślnego zestawu znaków serwera. Emoji i część znaków azjatyckich zamieniają się w znaki zapytania, a samo kodowanie staje się przy niektórych zestawach wektorem wstrzyknięcia. Zestaw znaków deklaruje się w DSN, nie zapytaniem SET NAMES.
  • ATTR_EMULATE_PREPARES ustawione na false. Domyślnie sterownik MySQL-a symuluje zapytania przygotowane po stronie PHP, zamiast wysyłać je na serwer. Po wyłączeniu emulacji to serwer oddziela zapytanie od danych, a wartości całkowite wracają jako liczby całkowite.
  • Jawne ATTR_ERRMODE. Od PHP 8.0 ERRMODE_EXCEPTION jest już wartością domyślną. Zostawienie go w kodzie niczego nie zmienia w zachowaniu, ale mówi czytelnikowi, że błędy wracają jako wyjątki.

Sprawdzenie na maszynie testowej:

php
$pdo2 = new PDO($dsn, $utilisateur, $motDePasse); // żadnych opcji
var_dump($pdo2->getAttribute(PDO::ATTR_ERRMODE) === PDO::ERRMODE_EXCEPTION);
// bool(true) na PHP 8.5.10

Hasła nie zapisuje się w pliku

Dane dostępowe nie mają czego szukać w wersjonowanym pliku. Najprostsza forma to odczytanie ich ze środowiska, z czytelnym komunikatem, kiedy zmiennej brakuje.

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,
        ],
    );
}

Operator ?: z następującym po nim throw działa od PHP 8.0, gdzie throw stało się wyrażeniem. Połączenie jest zapamiętane w zmiennej statycznej: jedno połączenie na żądanie w zupełności wystarczy.

Nigdy nie wypuszczaj komunikatu błędu na zewnątrz

Wyjątek połączenia zawiera host, użytkownika, a czasem hasło w śladzie stosu. Wyświetlony bez zmian na publicznej stronie oddaje atakującemu połowę roboty.

php
try {
    $pdo = connexion();
} catch (PDOException $e) {
    // Szczegóły trafiają do logu, nigdy do odpowiedzi HTTP
    error_log('Connexion base impossible : ' . $e->getMessage());
    http_response_code(503);
    exit('Service momentanément indisponible.');
}

Od PHP 8.2 parametry oznaczone #[SensitiveParameter] pojawiają się w śladzie stosu jako Object(SensitiveParameterValue). To przydatna ochrona, ale obejmuje wyłącznie ślad: sam komunikat pozostaje gadatliwy.

Połączenie to dopiero połowa roboty

Połączenie bez zapytań przygotowanych nie chroni przed niczym. Zasada mieści się w jednym zdaniu: danych nigdy nie skleja się z zapytaniem.

php
// Tak nie rób: wartość trafia wprost do zapytania
$sql = "SELECT * FROM membres WHERE email = '" . $_GET['email'] . "'";

// Zapytanie przygotowane: wartość podróżuje osobno
$requete = $pdo->prepare('SELECT id, email, cree_le FROM membres WHERE email = ?');
$requete->execute([$_GET['email'] ?? '']);
$membre = $requete->fetch();

Nazwane markery czytają się lepiej, gdy wartości jest więcej niż dwie:

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); // LIMIT zawsze z typem
$requete->execute();

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

PDO::PARAM_INT przy LIMIT nie jest ozdobnikiem, a jego działanie zależy od ustawienia, o którym często się zapomina. Oto co daje bindValue(':limite', 3) bez typowania, zależnie od tego, czy emulacja zapytań przygotowanych jest włączona:

Serwer EMULATE_PREPARES Bez PARAM_INT Z PARAM_INT
MariaDB 11.8.9 false przyjęte przyjęte
MariaDB 11.8.9 true (domyślnie) błąd SQL 1064 przyjęte
MySQL 8.4.11 false przyjęte przyjęte
MySQL 8.4.11 true (domyślnie) błąd SQL 1064 przyjęte

Przy włączonej emulacji PDO samo składa zapytanie i otacza wartość apostrofami: LIMIT '3', czego serwer nie przyjmuje. To konfiguracja domyślna, więc przypadek, na który trafia większość ludzi. Otypowanie wartości działa we wszystkich czterech sytuacjach i tylko tę formę warto zapamiętać.

Marker nigdy nie zastąpi nazwy tabeli ani kolumny. Jeśli nazwa kolumny pochodzi od użytkownika, waliduje się ją względem zamkniętej listy:

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";

A co z mysqli?

mysqli nadal ma sens, jeśli projekt nigdy nie będzie rozmawiał z niczym poza MySQL-em i MariaDB. Jego zachowanie zmieniło się w jednym istotnym punkcie: od PHP 8.1 mysqli_report() jest ustawione na MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT, a błędy rzucają wyjątek zamiast zwracać 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();

Test połączenia, który niczego już nie testuje

Poniższy wzorzec ciągnie się przez mnóstwo poradników, w tym przez wcześniejsze wersje tego. Od PHP 8.1 nie działa.

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

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

Uruchomiony na PHP 8.5.10 z błędnym hasłem, ten kod nie wypisuje przewidzianego komunikatu. Wypisuje to:

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

Linia if (!$conn) nigdy nie zostaje osiągnięta: mysqli_connect() rzuciło wyjątek wcześniej. die() jest martwym kodem, a surowy błąd leci do przeglądarki, jeśli wyświetlanie błędów jest włączone. Zapis z try / catch pokazany wyżej to dziś jedyna poprawna forma.

Funkcje mysql_* już nie istnieją

Spotykane jeszcze w starych skryptach mysql_connect(), mysql_query() i mysql_real_escape_string() zostały usunięte z PHP 7.0, wydanego w 2015 roku. Na PHP 8.5:

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

Nie ma tu żadnej kompatybilności do utrzymania, jest tylko migracja do zrobienia, najlepiej na PDO.

Co dalej

Przy wstawianiu dużej liczby wierszy połączenie to dopiero połowa tematu: zobacz czytanie dużych plików w PHP, gdzie pokazane jest wstawianie partiami wewnątrz transakcji. Do przekierowania po zakończonej obsłudze zobacz przekierowanie w PHP. Kiedy połączenie już działa, kolejny krok to nie budować go od nowa w każdym pliku. Pozostałe poradniki PHP w hubie Programowanie webowe pokrywają sąsiednie klocki: instalacja Composera, żeby uporządkować projekt, oraz podstawy programowania obiektowego w PHP, żeby schować to połączenie w klasie zamiast w globalnej funkcji.

Częste błędy

die(), które nigdy się nie wykona Od PHP 8.1 mysqli_connect() rzuca mysqli_sql_exception, zanim wykonanie dojdzie do if (!$conn). Do przeglądarki leci surowy komunikat błędu zamiast tego, który przewidziałeś.
Brak charsetu w DSN Bez charset=utf8mb4 emoji i część znaków zamieniają się w znaki zapytania, a kodowanie może posłużyć jako wektor wstrzyknięcia.
LIMIT bez PDO::PARAM_INT Przy emulacji zapytań przygotowanych, włączonej domyślnie, PDO zapisuje LIMIT '3', a serwer zwraca błąd SQL 1064. Sprawdzone na MariaDB 11.8.9 i MySQL 8.4.11.
Wyświetlony komunikat wyjątku Ślad stosu PDOException zawiera host i użytkownika. Trafia do logu, nigdy do odpowiedzi HTTP.
Marker w miejscu nazwy kolumny Marker zastępuje wyłącznie wartość. Nazwę tabeli albo kolumny waliduje się względem zamkniętej listy.

MySQLPDOPHPSécurité

Damien Flandrin Web developer od 2010 roku, twórca Gekkode i Email Impact. Każdy artykuł jest sprawdzany na prawdziwym projekcie przed publikacją. Kontakt
Newsletter

Nowe testy, poradniki i projekty — e-mailem.

Powtarzalne testy, wersjonowany kod, datowane wyniki. Nigdy spamu.