Gdy aplikacja PHP ma zapisywać formularze, konta użytkowników albo zamówienia, samo napisanie zapytania SQL to dopiero początek. PDO daje spójny sposób komunikacji z bazą, obsługuje przygotowane zapytania i pozwala bezpieczniej pracować z MySQL, PostgreSQL czy SQLite. Pokażę, jak skonfigurować połączenie, wykonywać operacje CRUD, obsługiwać błędy i korzystać z transakcji bez typowych pułapek.
PDO porządkuje dostęp aplikacji PHP do danych
- Jedno API pozwala pracować z różnymi bazami danych.
- Prepared statements ograniczają ryzyko SQL injection.
- ERRMODE_EXCEPTION ułatwia wykrywanie błędów w kodzie.
- Transakcje chronią operacje, które muszą wykonać się razem.
- Bind parameters nie zastępuje walidacji danych ani kontroli uprawnień.
Co daje PDO i kiedy warto z niego korzystać
PDO, czyli PHP Data Objects, jest warstwą dostępu do baz danych. Programista korzysta z klas takich jak PDO i PDOStatement, a szczegóły komunikacji z konkretnym silnikiem przejmuje sterownik. Dzięki temu większość kodu wygląda podobnie niezależnie od tego, czy aplikacja używa MySQL, PostgreSQL czy SQLite.
To nie jest ORM, czyli narzędzie mapujące tabele na obiekty. PDO pozostawia Ci bezpośrednią kontrolę nad SQL-em. Moim zdaniem jest to świetny poziom dla małych i średnich aplikacji, API, paneli administracyjnych oraz projektów edukacyjnych, bo uczy prawdziwej pracy z bazą bez dokładania dużej warstwy abstrakcji.
| Rozwiązanie | Największa zaleta | Ograniczenie |
|---|---|---|
| PDO | Wspólne API i prepared statements | Potrzebuje właściwego sterownika |
| MySQLi | Dobre wsparcie dla MySQL | Działa tylko z MySQL |
| ORM | Mniej ręcznego SQL i relacje jako obiekty | Większa złożoność i narzut |
PDO nie przyspieszy automatycznie każdej aplikacji i nie naprawi źle zaprojektowanych indeksów. Jego największa wartość to przewidywalny kod, bezpieczne przekazywanie parametrów i wygodne zarządzanie błędami. Trzeba jednak pamiętać, że do MySQL potrzebujesz rozszerzenia pdo_mysql, do PostgreSQL pdo_pgsql, a do SQLite pdo_sqlite.
Jak poprawnie skonfigurować połączenie z bazą
Połączenie tworzysz za pomocą DSN, czyli ciągu opisującego sterownik, host, nazwę bazy i dodatkowe ustawienia. Dla MySQL rozsądny punkt wyjścia wygląda tak:
PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
try {
$db = new PDO($dsn, $username, $password, $options);
} catch (PDOException $e) {
error_log($e->getMessage());
exit('Nie udało się połączyć z bazą danych.');
}
ERRMODE_EXCEPTION sprawia, że błąd zapytania zgłasza wyjątek zamiast cicho zwracać wartość false. To ważne, bo bez tego łatwo przeoczyć problem i później diagnozować jego skutki w zupełnie innym miejscu aplikacji. Dokumentacja PHP opisuje ten tryb jako standardowy sposób pracy z wyjątkami PDO.
Ustawienie PDO::FETCH_ASSOC powoduje, że wyniki odczytasz jako tablice z nazwami kolumn. Z kolei ATTR_EMULATE_PREPARES => false wymusza natywne przygotowywanie zapytań tam, gdzie pozwala na to sterownik. Nie jest to magiczna tarcza bezpieczeństwa, ale eliminuje część nieprzewidywalnych zachowań emulowanych zapytań.
DSN dla popularnych silników
| Baza | Przykładowy DSN | Na co uważać |
|---|---|---|
| MySQL | mysql:host=127.0.0.1;dbname=app;charset=utf8mb4 |
Ustaw kodowanie utf8mb4
|
| PostgreSQL | pgsql:host=127.0.0.1;port=5432;dbname=app |
Sprawdź port i schemat |
| SQLite | sqlite:/var/data/app.sqlite |
Proces PHP musi mieć dostęp do pliku |
Dane logowania trzymaj w zmiennych środowiskowych albo bezpiecznej konfiguracji wdrożenia. Umieszczenie hasła w repozytorium, nawet prywatnym, to prosty błąd operacyjny, który może później kosztować znacznie więcej niż samo napisanie poprawnego kodu.
Prepared statements to podstawa bezpiecznych zapytań
Przygotowane zapytanie oddziela instrukcję SQL od wartości dostarczanych przez użytkownika. Zamiast wklejać dane do tekstu zapytania, umieszczasz w nim nazwany parametr, a wartość przekazujesz dopiero przy wykonaniu. Tak działa podstawowy wzorzec:
prepare($sql);
$stmt->execute([
'email' => $email,
]);
$user = $stmt->fetch();
Parametry mogą mieć postać nazwanych znaczników, takich jak :email, albo znaków zapytania. W jednym zapytaniu wybierz tylko jeden styl. Przy parametrach nazwanych używaj unikalnych nazw dla każdej wartości, ponieważ ponowne wykorzystanie tego samego znacznika może działać różnie zależnie od sterownika i ustawień emulacji. Zasady dotyczące prepare() i execute() są szczegółowo opisane w dokumentacji PHP.
Parametrów nie stosuje się do nazw kolumn
PDO bez problemu zabezpieczy wartość wyszukiwanego adresu e-mail, ale nie zastąpi nazwą kolumny fragmentu ORDER BY. Taki kod nie zadziała poprawnie:
$sql = 'SELECT * FROM products ORDER BY :column';
Jeżeli użytkownik wybiera sortowanie, zastosuj białą listę dozwolonych wartości:
'name',
'price' => 'price',
];
$sortKey = $_GET['sort'] ?? 'name';
$sortColumn = $allowedSorts[$sortKey] ?? $allowedSorts['name'];
$sql = "SELECT id, name, price FROM products ORDER BY {$sortColumn}";
$products = $db->query($sql)->fetchAll();
To samo dotyczy nazw tabel, kierunku sortowania i fragmentów SQL, których nie można przekazać jako zwykłych parametrów. Prepared statement chroni dane, ale nie pozwala bezrefleksyjnie budować całej składni zapytania z wartości pochodzących od użytkownika.
Odczyt, dodawanie i aktualizacja danych w praktyce
Do pojedynczego odczytu używam fetch(), a do pobrania całego zestawu wyników fetchAll(). Dla dużych tabel lepiej przechodzić po wynikach w pętli, aby nie ładować wszystkich rekordów do pamięci naraz.
prepare('
SELECT id, name, price
FROM products
WHERE price >= :minimum_price
ORDER BY price ASC
');
$stmt->execute([
'minimum_price' => 100,
]);
foreach ($stmt as $product) {
echo htmlspecialchars($product['name'], ENT_QUOTES, 'UTF-8');
}
Funkcja htmlspecialchars() w przykładzie nie zabezpiecza SQL-a. Chroni miejsce, w którym dane z bazy trafiają do HTML-a, czyli ogranicza ryzyko XSS. To ważne rozróżnienie, bo bezpieczeństwo danych działa warstwami.
Dodawanie rekordów
prepare('
INSERT INTO products (name, price)
VALUES (:name, :price)
');
$stmt->execute([
'name' => $name,
'price' => $price,
]);
$newId = $db->lastInsertId();
Wartość ceny powinna być wcześniej zweryfikowana po stronie aplikacji i dodatkowo ograniczona typem oraz regułami w bazie. Samo PDO nie oceni, czy cena jest dodatnia, czy nazwa ma rozsądną długość. Po udanej aktualizacji możesz sprawdzić rowCount(), ale pamiętaj, że znaczenie tej wartości bywa zależne od sterownika i konfiguracji bazy.
Aktualizacja i usuwanie
prepare('
UPDATE products
SET price = :price
WHERE id = :id
');
$update->execute([
'price' => $newPrice,
'id' => $productId,
]);
$delete = $db->prepare('DELETE FROM products WHERE id = :id');
$delete->execute(['id' => $productId]);
W kodzie produkcyjnym nie pozwalam, aby identyfikator rekordu pochodził bezpośrednio z adresu URL bez sprawdzenia. Najpierw waliduję, czy jest liczbą, a później weryfikuję, czy zalogowany użytkownik ma prawo modyfikować wskazany rekord. Poprawne zapytanie nie zastępuje autoryzacji.
Transakcje i obsługa błędów bez utraty spójności
Transakcja jest potrzebna wtedy, gdy kilka zmian tworzy jedną logiczną operację. Przykładem może być utworzenie zamówienia i odjęcie towaru ze stanu magazynowego. Jeżeli drugi krok się nie powiedzie, pierwszy również powinien zostać wycofany.
beginTransaction();
$order = $db->prepare('
INSERT INTO orders (user_id, total)
VALUES (:user_id, :total)
');
$order->execute([
'user_id' => $userId,
'total' => $total,
]);
$stock = $db->prepare('
UPDATE products
SET stock = stock - :quantity
WHERE id = :product_id AND stock >= :quantity
');
$stock->execute([
'quantity' => $quantity,
'product_id' => $productId,
]);
if ($stock->rowCount() !== 1) {
throw new RuntimeException('Brak wystarczającego stanu magazynowego.');
}
$db->commit();
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
error_log($e->getMessage());
throw $e;
}
commit() zatwierdza wszystkie zmiany, a rollBack() cofa je od momentu rozpoczęcia transakcji. Nie każda tabela i nie każda baza obsługuje transakcje identycznie. W MySQL znaczenie ma między innymi silnik tabel, dlatego dla operacji biznesowych wybieram tabele InnoDB i nie mieszam zmian schematu z transakcją danych.
Błędu nie powinno się pokazywać użytkownikowi wprost. Komunikat wyjątku może zawierać nazwę tabeli, zapytanie albo szczegóły infrastruktury. Na stronie wyświetl przyjazny komunikat, a szczegóły zapisz do logów. Dokumentacja PHP zwraca uwagę także na ograniczenia transakcji oraz możliwe niejawne zatwierdzenia przy operacjach DDL, takich jak CREATE TABLE czy DROP TABLE.
Przeczytaj również: Jaka baza danych? Wybierz dobrze - SQL, NoSQL i specjalizowane
Najczęstsze błędy w pracy z PDO
- Wstawianie danych do SQL-a przez konkatenację zamiast użycia parametrów.
- Brak ustawienia
ERRMODE_EXCEPTIONi ignorowanie wartościfalse. - Mieszanie parametrów nazwanych z pozycyjnymi w jednym zapytaniu.
- Używanie
SELECT *, mimo że aplikacja potrzebuje tylko kilku kolumn. - Brak indeksu na kolumnie używanej często w
WHERE. - Traktowanie PDO jako zamiennika walidacji, autoryzacji i ochrony wyjścia HTML.
W praktyce największą różnicę robią trzy rzeczy: parametryzacja zapytań, sensowny model danych i poprawnie używane transakcje. Samo utworzenie obiektu PDO jest proste, ale dopiero konsekwencja w całej warstwie dostępu do danych daje bezpieczny rezultat.
Dobry punkt wyjścia do dalszego rozwoju aplikacji
Na początek wystarczy jedna klasa odpowiedzialna za połączenie, przygotowane zapytania i jasno określony sposób obsługi wyjątków. Później możesz wydzielić repozytoria, dodać migracje, testy integracyjne oraz osobne logowanie błędów. Nie ma sensu wprowadzać rozbudowanego ORM-u tylko dlatego, że projekt wykonuje kilka prostych operacji na tabeli.
Moja praktyczna rekomendacja jest prosta: ustaw tryb wyjątków, korzystaj z parametrów, włącz właściwe kodowanie, kontroluj uprawnienia i używaj transakcji tam, gdzie dane muszą zmienić się razem. Taki zestaw zasad wystarcza, aby PDO w PHP było bezpieczną i elastyczną podstawą wielu aplikacji korzystających z SQL.
