Wprowadzenie
Bazy danych odgrywają kluczową rolę w tworzeniu dynamicznych aplikacji webowych. PHP, jako popularny język skryptowy, oferuje wbudowane rozszerzenia umożliwiające komunikację z bazami danych – w szczególności z systemem MySQL. W przeszłości używano starszego rozszerzenia MySQL (funkcje typu mysql_connect()
), ale zostało ono uznane za przestarzałe już w 2012 roku. Obecnie do obsługi MySQL w PHP wykorzystujemy MySQLi (ang. MySQL Improved) lub PDO (ang. PHP Data Objects). Niniejszy wykład przeznaczony jest dla początkujących i średniozaawansowanych programistów PHP i omawia szczegółowo, jak połączyć się z bazą danych oraz wykonywać na niej operacje, z naciskiem na dobre praktyki i bezpieczeństwo.
MySQLi vs PDO: Oba nowoczesne rozwiązania mają swoje zalety. MySQLi jest dedykowane tylko dla bazy MySQL (nie obsłuży innych silników baz danych), natomiast PDO stanowi uniwersalną warstwę abstrakcji pozwalającą na komunikację z wieloma różnymi bazami (obsługuje ponad 12 różnych systemów baz danych). Dzięki PDO potencjalna migracja aplikacji z MySQL na inny system (np. PostgreSQL) jest łatwiejsza – często wystarczy zmienić ciąg połączenia i ewentualnie kilka zapytań, zamiast przepisywać całość kodu. MySQLi oferuje zarówno interfejs obiektowy, jak i proceduralny, podczas gdy PDO jest dostępne tylko w stylu obiektowym. Co ważne, oba podejścia wspierają mechanizm tzw. prepared statements (zapytania z parametrami), które zabezpieczają przed atakami SQL injection – ten temat omówimy szerzej w dalszej części wykładu.
W kolejnych sekcjach pokażemy, jak nawiązać połączenie z bazą MySQL przy użyciu MySQLi i PDO, jak wykonywać podstawowe operacje CRUD (wynikające z ang. Create, Read, Update, Delete – czyli Insert, Select, Update, Delete), porównamy MySQLi z PDO, omówimy obsługę błędów, wykorzystanie zapytań z parametrami oraz najlepsze praktyki związane z bezpieczeństwem i organizacją kodu.
Tworzenie połączenia z bazą danych MySQL w PHP
Aby korzystać z bazy danych w PHP, najpierw musimy nawiązać połączenie z serwerem bazy danych i wybrać odpowiednią bazę. Zakładamy, że na serwerze MySQL została utworzona przykładowa baza danych (np. my_database
) oraz że posiadamy dane dostępowe: host (np. localhost
), nazwę użytkownika, hasło i nazwę bazy. Pokażemy dwa sposoby połączenia: za pomocą MySQLi oraz PDO.
Połączenie za pomocą MySQLi
W przypadku MySQLi można korzystać ze stylu obiektowego lub proceduralnego. Skupimy się na stylu obiektowym (klasa mysqli
), ponieważ jest on zbliżony koncepcją do PDO i ułatwia to porównanie. Przykład poniżej pokazuje, jak utworzyć połączenie:
<?php
// Dane konfiguracyjne bazy danych
$host = "localhost";
$user = "uzytkownik"; // nazwa użytkownika bazy
$password = "haslo"; // hasło użytkownika bazy
$database = "my_database"; // nazwa bazy danych
// Utworzenie połączenia (obiekt MySQLi)
$conn = new mysqli($host, $user, $password, $database);
// Ustawienie zestawu znaków na UTF-8 (zalecane dla polskich znaków i bezpieczeństwa)
$conn->set_charset("utf8mb4");
// Sprawdzenie, czy połączenie się powiodło
if ($conn->connect_error) {
die("Błąd połączenia: " . $conn->connect_error);
}
echo "Połączenie z bazą danych MySQL za pomocą MySQLi powiodło się!";
?>
W powyższym kodzie tworzymy nowy obiekt mysqli
, przekazując do konstruktora kolejno host, nazwę użytkownika, hasło oraz nazwę bazy danych. Jeśli chcemy najpierw połączyć się z serwerem bez wybierania bazy, możemy pominąć nazwę bazy w konstruktorze – jednak zazwyczaj od razu podaje się bazę, z której będziemy korzystać. Zalecane jest również ustawienie odpowiedniego zestawu znaków (encoding) na połączeniu, np. UTF-8 (utf8mb4
), aby zapewnić poprawne działanie polskich znaków i zabezpieczyć się przed potencjalnymi problemami z kodowaniem danych.
Po wykonaniu $conn = new mysqli(...)
warto sprawdzić, czy połączenie doszło do skutku. Obiekt mysqli
posiada właściwość $connect_error
(oraz odpowiadającą jej metodę connect_error()
w stylu proceduralnym), która zawiera komunikat błędu w przypadku nieudanej próby logowania. W powyższym kodzie, jeśli wystąpił błąd, przerywamy działanie skryptu poprzez die()
i wyświetlamy komunikat. W środowisku produkcyjnym zamiast die()
lepiej jest obsłużyć błąd w inny sposób (np. logując go), ale na etapie nauki lub w prostych skryptach takie rozwiązanie pozwala szybko wykryć problem.
Uwaga: MySQLi domyślnie nie rzuca wyjątków przy błędach, dlatego musimy sprawdzać błędy ręcznie (np. connect_error
czy później $conn->error
po wykonaniu zapytań). Istnieje możliwość włączenia stylu wyjątków w MySQLi (np. poprzez mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT)
– o czym później), ale jest to opcjonalne.
Połączenie za pomocą PDO
PDO oferuje jednolity interfejs do wielu baz danych, w tym MySQL. Aby nawiązać połączenie, tworzymy instancję klasy PDO
. Musimy podać tzw. DSN (Data Source Name), czyli specjalny ciąg zawierający informacje o rodzaju bazy, hoście, nazwie bazy itp., a także nazwę użytkownika i hasło. Przykład:
<?php
// Dane konfiguracyjne bazy (jak wcześniej)
$host = "localhost";
$user = "uzytkownik";
$password = "haslo";
$database = "my_database";
try {
// Utworzenie połączenia PDO (DSN zawiera nazwę bazy i zestaw znaków)
$dsn = "mysql:host=$host;dbname=$database;charset=utf8mb4";
$pdo = new PDO($dsn, $user, $password);
// Ustawienie trybu raportowania błędów na wyjątki
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
echo "Połączenie z bazą danych MySQL za pomocą PDO powiodło się!";
} catch (PDOException $e) {
// Obsługa błędu połączenia
echo "Błąd połączenia: " . $e->getMessage();
}
?>
W powyższym kodzie ciąg DSN ma format mysql:host=<host>;dbname=<baza>;charset=<znaki>
. Ustawienie charset=utf8mb4
jest ważne, tak samo jak w MySQLi, aby komunikacja z bazą odbywała się w UTF-8. Następnie przekazujemy DSN, nazwę użytkownika i hasło do konstruktora PDO. Ponieważ próba utworzenia new PDO(...)
może zgłosić wyjątek w przypadku błędu (np. błędne dane dostępowe), otaczamy tę operację w blok try-catch
.
Warto zaraz po połączeniu ustawić atrybut PDO::ATTR_ERRMODE
na PDO::ERRMODE_EXCEPTION
, co spowoduje, że wszelkie błędy bazy danych będą zgłaszane jako wyjątki (PDO domyślnie w trybie silent lub warning, w zależności od ustawień, więc explicite wymuszamy wyjątki). Dzięki temu kod w bloku catch
wychwyci nie tylko ewentualne błędy samego połączenia, ale również późniejsze błędy zapytań (jeśli będziemy je wykonywać w tym samym try-catch lub kolejnych).
Należy zauważyć pewną różnicę: w PDO już w momencie nawiązywania połączenia musimy podać nazwę bazy (dbname
). Jeśli tego nie zrobimy, PDO zgłosi błąd – wymagane jest istnienie wskazanej bazy danych podczas łączenia. W przypadku MySQLi możliwe jest połączenie bez wyboru bazy (co odpowiada wykonaniu osobno komendy SELECT DATABASE()
później), lecz zwykle i tak zaraz po zalogowaniu wybiera się jedną bazę docelową.
Podsumowanie połączenia: Zarówno MySQLi, jak i PDO po poprawnym wykonaniu zwracają obiekt połączenia ($conn
lub $pdo
), którego będziemy używać do wykonywania dalszych operacji na bazie. Pamiętajmy o zabezpieczeniu danych dostępowych – w kodzie powyżej zostały one jawnie wpisane dla czytelności, ale w praktyce trzyma się je poza głównym kodem (np. w oddzielnym pliku konfiguracyjnym). W dalszych przykładach zakładamy, że połączenie zostało już utworzone i zachowane w zmiennej (odpowiednio $conn
dla MySQLi lub $pdo
dla PDO).
Wykonywanie podstawowych zapytań SQL (SELECT, INSERT, UPDATE, DELETE)
Po ustanowieniu połączenia możemy wykonywać zapytania SQL do bazy. Operacje te dzielą się na odczytywanie danych (SELECT) oraz modyfikowanie danych (INSERT – dodawanie nowych rekordów, UPDATE – aktualizacja istniejących, DELETE – usuwanie). Pokażemy podstawowe przykłady tych zapytań w kodzie PHP. Na początek użyjemy podejścia „bezpośredniego” (tzn. wstrzykujemy zapytanie SQL jako ciąg znaków). Później omówimy korzystniejsze podejście z użyciem przygotowanych zapytań (prepared statements), ale ważne jest zrozumieć najpierw podstawy.
Przykłady zapytań z użyciem MySQLi
Zakładamy, że posiadamy obiekt połączenia MySQLi w zmiennej $conn
(utworzonej jak wyżej). Możemy korzystać z metody $conn->query(...)
do wykonywania zapytań, która zwraca obiekt wynikowy dla zapytań typu SELECT lub wartość boolean (TRUE/FALSE) dla zapytań modyfikujących.
- SELECT – Pobieranie danych z bazy:
// Przykładowe zapytanie SELECT: pobranie wszystkich użytkowników
$sql = "SELECT * FROM users";
$result = $conn->query($sql);
if ($result) { // Sprawdź ile wierszy zwrócono
if ($result->num_rows > 0) { // Iteracja po rezultatach
while ($row = $result->fetch_assoc()) {
echo "ID: " . $row['id'] . ", Nazwa: " . $row['name'] . "<br>"; }
} else {
echo "Brak wyników zapytania.";
} // Zwolnienie pamięci wyniku
$result->free_result();
} else {
echo "Błąd w zapytaniu SELECT: " . $conn->error;
}
W powyższym kodzie tworzymy zapytanie SQL wybierające wszystkie kolumny ze wszystkich rekordów tabeli users
. Metoda $conn->query()
zwraca obiekt klasy mysqli_result
w przypadku powodzenia. Sprawdzamy najpierw, czy $result
nie jest wartością fałszywą (FALSE) – jeśli byłoby FALSE, oznacza to błąd w zapytaniu i wtedy wypisujemy komunikat błędu, korzystając z $conn->error
. Jeśli zapytanie się powiodło, możemy sprawdzić liczbę zwróconych wierszy przez $result->num_rows
. Następnie odczytujemy kolejne wiersze wyniku za pomocą $result->fetch_assoc()
, który zwraca tablicę skojarzoną reprezentującą kolejny wiersz (klucze tablicy odpowiadają nazwom kolumn). Iterujemy w pętli while
dopóki są rekordy, wypisując np. identyfikator i nazwę użytkownika. Na koniec, gdy wynik nie jest już potrzebny, wywołujemy $result->free_result()
w celu zwolnienia zasobów (nie jest to absolutnie wymagane, bo po zakończeniu skryptu PHP sam posprząta, ale jest to dobra praktyka przy dużych wynikach lub długotrwałych skryptach).
- INSERT – Dodawanie nowego rekordu do bazy:
// Przykładowe zapytanie INSERT: dodanie nowego użytkownika
$sql = "INSERT INTO users (name, email) VALUES ('Jan Kowalski', 'jan@example.com')";
if ($conn->query($sql) === TRUE) {
echo "Dodano nowy rekord użytkownika!"; // Możemy uzyskać ID dodanego rekordu
$newId = $conn->insert_id;
echo " ID nowego użytkownika: $newId";
} else {
echo "Błąd podczas dodawania rekordu: " . $conn->error;
}
W tym przykładzie konstruujemy zapytanie SQL dodające użytkownika z imieniem i emailem do tabeli users
. Metoda $conn->query()
zwraca TRUE
gdy operacja się powiedzie (dla zapytań, które nie zwracają danych). Sprawdzamy więc wynik porównując z TRUE (operator ===
jest tu użyty do pewnego rozróżnienia typu). W przypadku sukcesu, możemy np. pobrać identyfikator dodanego rekordu przez właściwość $conn->insert_id
. Gdy nastąpi błąd (np. naruszenie unikalności klucza), wchodzimy w blok else
i wypisujemy komunikat błędu.
- UPDATE – Modyfikacja istniejących danych:
// Przykładowe zapytanie UPDATE: zmiana adresu email użytkownika o id=5
$sql = "UPDATE users SET email='jan.nowy@example.com' WHERE id=5";
if ($conn->query($sql) === TRUE) {
echo "Zaktualizowano dane użytkownika.";
echo " Liczba zmienionych wierszy: " . $conn->affected_rows;
} else {
echo "Błąd podczas aktualizacji: " . $conn->error;
}
Tutaj zapytanie zmienia adres email użytkownika o identyfikatorze 5. Po wykonaniu sprawdzamy, czy $conn->query()
zwróciło TRUE. Jeżeli tak, można dodatkowo sprawdzić ile wierszy zostało zmodyfikowanych poprzez $conn->affected_rows
. Jeśli zapytanie nie zmieniło żadnego wiersza (np. podane dane były identyczne z istniejącymi lub nie istniał użytkownik o id=5), affected_rows
może zwrócić 0 mimo braku błędu – warto mieć to na uwadze. W razie błędu (FALSE), wypisujemy komunikat z error
.
- DELETE – Usuwanie rekordów:
// Przykładowe zapytanie DELETE: usunięcie użytkownika o id=5
$sql = "DELETE FROM users WHERE id=5";
if ($conn->query($sql) === TRUE) {
echo "Usunięto rekord użytkownika.";
echo " Liczba usuniętych wierszy: " . $conn->affected_rows;
} else {
echo "Błąd podczas usuwania: " . $conn->error;
}
Operacja usunięcia jest analogiczna do update – sprawdzamy powodzenie i ewentualnie ilość usuniętych rekordów. Użycie klauzuli WHERE
jest bardzo istotne; bez niej polecenie DELETE usunęłoby wszystkie wiersze tabeli!
Powyższe przykłady pokazują podstawowy schemat pracy z bazą: tworzymy zapytanie SQL w formie stringa, wykonujemy je przez metodę biblioteczną, a następnie weryfikujemy wynik. Warto zawsze obsłużyć sytuację błędną (stąd konstrukcje if/else
sprawdzające wynik i wypisujące ewentualne błędy). W prostych skryptach wyświetlanie $conn->error
jest pomocne podczas debugowania, natomiast w aplikacjach produkcyjnych raczej logujemy te błędy po stronie serwera, zamiast prezentować je użytkownikowi (kwestie bezpieczeństwa – o czym dalej).
Przykłady zapytań z użyciem PDO
Posiadając obiekt PDO w zmiennej $pdo
, możemy wykonywać zapytania na kilka sposobów. PDO udostępnia m.in. metodę $pdo->query()
(podobnie jak MySQLi) oraz $pdo->exec()
. Różnica jest taka, że $pdo->query()
zwraca obiekt klasy PDOStatement
dla zapytań SELECT (lub innych zwracających dane), natomiast $pdo->exec()
zwraca jedynie liczbę zmienionych wierszy i służy głównie do zapytań typu INSERT/UPDATE/DELETE. Dodatkowo, ponieważ użyliśmy trybu wyjątków w PDO, w razie błędu zapytania zostanie zgłoszony wyjątek PDOException
. Dla czytelności będziemy używać bloku try-catch w przykładach PDO, choć w prostych przypadkach można polegać na globalnym obsługiwaniu wyjątków.
- SELECT – Pobieranie danych:
try {
$sql = "SELECT * FROM users";
$stmt = $pdo->query($sql); // Pętla po wynikach (korzystamy z fetch(PDO::FETCH_ASSOC))
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
echo "ID: {$row['id']}, Nazwa: {$row['name']}<br>";
} // Alternatywnie można pobrać wszystkie wyniki naraz: //
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
echo "Błąd zapytania SELECT: " . $e->getMessage();
}
Powyżej wykonujemy zapytanie SELECT analogiczne do wcześniejszego. Metoda $pdo->query()
zwraca obiekt $stmt
(statement). Następnie wywołujemy $stmt->fetch(PDO::FETCH_ASSOC)
w pętli, aby uzyskać kolejne wiersze wyników jako tablice asocjacyjne (klasa PDO oferuje różne tryby pobierania danych, PDO::FETCH_ASSOC
jest jednym z najczęściej używanych – zwraca wynik jak mysqli_fetch_assoc
). W komentarzu pokazano też alternatywę: można skorzystać z $stmt->fetchAll(...)
, aby od razu pobrać całą tablicę wyników jedną komendą. Pamiętajmy, że po wykorzystaniu wyniku możemy (choć nie musimy) zamknąć kursor wyniku poprzez $stmt->closeCursor()
lub po prostu przypisać $stmt = null
aby upewnić się, że zasoby zostały zwolnione.
- INSERT – Dodawanie danych:
try {
$sql = "INSERT INTO users (name, email) VALUES ('Anna Nowak', 'anna@example.com')";
$rowsAffected = $pdo->exec($sql);
echo "Dodano rekordów: $rowsAffected";
$newId = $pdo->lastInsertId();
echo " ID nowego użytkownika: $newId";
} catch (PDOException $e) {
echo "Błąd INSERT: " . $e->getMessage();
}
Tutaj używamy metody $pdo->exec()
do wykonania zapytania dodającego rekord. Zmienna $rowsAffected
powinna zawierać liczbę dodanych wierszy (w tym wypadku 1, o ile operacja się powiodła). Metoda lastInsertId()
pozwala pobrać identyfikator wygenerowany przez ostatnie zapytanie INSERT
(pod warunkiem, że tabela ma kolumnę typu AUTO_INCREMENT). Jeśli wystąpi błąd (np. złamanie ograniczeń bazy), zostanie zgłoszony wyjątek i przechwycony w catch
.
- UPDATE / DELETE – Modyfikacja i usuwanie danych:
try {
$sql = "UPDATE users SET email='anna.nowak@example.com' WHERE name='Anna Nowak'";
$rowsAffected = $pdo->exec($sql);
echo "Zaktualizowano rekordów: $rowsAffected";
$sql = "DELETE FROM users WHERE id=10";
$rowsDeleted = $pdo->exec($sql);
echo "Usunięto rekordów: $rowsDeleted";
} catch (PDOException $e) {
echo "Błąd modyfikacji danych: " . $e->getMessage();
}
W powyższym fragmencie w jednym bloku try
wykonujemy dwa zapytania: najpierw aktualizacja (zmiana adresu email dla użytkownika o imieniu „Anna Nowak”), potem usunięcie użytkownika o id=10. pdo->exec()
zwraca odpowiednio liczbę zmienionych lub usuniętych wierszy, co wykorzystujemy do poinformowania o wyniku. W razie jakiegokolwiek błędu w którymkolwiek zapytaniu, kontrola przejdzie do sekcji catch
. Jeśli chcielibyśmy obsłużyć te operacje osobno, można oczywiście użyć dwóch odrębnych bloków try-catch.
Jak widać, składnia zapytań SQL jest identyczna niezależnie od tego, czy używamy MySQLi czy PDO – różnią się jedynie sposoby wywołania tych zapytań i obsługi wyników. MySQLi wymaga użycia różnych metod (np. query()
, fetch_assoc()
, właściwości jak affected_rows
), podczas gdy PDO używa obiektu PDOStatement oraz metod takich jak fetch()
czy rowCount()
. Warto też zauważyć, że w powyższych przykładach PDO nie wymagało jawnego zamykania połączenia czy wyniku – po zakończeniu skryptu wszystko jest automatycznie zwalniane, a w przypadku braku dodatkowych odniesień do $stmt
lub $pdo
, PHP sam zwolni zasoby (choć o zamykaniu połączeń będzie więcej w sekcji o dobrych praktykach).
Uwaga: Przedstawione powyżej wykonywanie zapytań poprzez bezpośrednie wstawianie wartości do stringa SQL działa, ale nie jest bezpieczne, jeśli te wartości pochodzą od użytkownika. Na przykład, w zapytaniu INSERT wpisaliśmy na sztywno wartości 'Anna Nowak'
i 'anna@example.com'
. Gdyby jednak te dane pochodziły z formularza (np. $_POST
), należałoby je odpowiednio zabezpieczyć. Najlepszym sposobem jest użycie przygotowanych zapytań (prepared statements), które omówimy za chwilę. Jeśli z jakiegoś powodu nie używamy prepared statements, absolutnym minimum jest filtrowanie lub eskejpownie danych wejściowych (np. mysqli_real_escape_string()
dla każdej zmiennej wstawianej do SQL) – inaczej aplikacja będzie narażona na ataki SQL injection.
Różnice między MySQLi a PDO – wady i zalety
Wybór między MySQLi a PDO zależy od potrzeb projektu oraz preferencji programisty. Poniżej zestawiamy główne różnice, wraz z ich konsekwencjami:
- Obsługiwane bazy danych: MySQLi działa tylko z bazą MySQL/MariaDB, natomiast PDO jest interfejsem uniwersalnym, obsługującym wiele silników baz danych (m.in. MySQL, PostgreSQL, SQLite, Microsoft SQL Server i inne). Jeśli istnieje potrzeba potencjalnego przeniesienia aplikacji na inną bazę lub jednoczesnej obsługi różnych baz, PDO daje taką elastyczność. W MySQLi zmiana bazy z MySQL na inną wymagałaby napisania dużej części kodu od nowa (w tym wszystkich zapytań).
- Interfejs proceduralny vs obiektowy: MySQLi udostępnia dwie formy API – obiektową (przez klasę
mysqli
oraz obiekty wyników) i proceduralną (poprzez funkcjemysqli_*
). Oba sposoby robią to samo, różnią się stylem kodowania. PDO natomiast jest wyłącznie obiektowe. Dla osób początkujących podejście proceduralne MySQLi bywa czasem łatwiejsze do zrozumienia (przypomina stary sposób użycia mysql_*), jednak podejście obiektowe jest bardziej nowoczesne i skaluje się lepiej w większych aplikacjach. Warto dodać, że obie biblioteki są obiektowe pod spodem – nawet używając MySQLi proceduralnie, PHP wewnętrznie i tak korzysta z obiektów. To tylko kwestia składni i organizacji kodu. - Składnia zapytań z parametrami: Obydwie biblioteki wspierają prepared statements (zapytania z parametrami), jednak różnią się nieco ich składnią. MySQLi w zapytaniach przygotowanych używa znaków zapytania
?
jako tzw. anonimowych (pozycyjnych) miejsc na parametry. PDO domyślnie pozwala na nazwane parametry (np.:name
,:email
w treści SQL) lub również znaki?
. Nazwane parametry mogą poprawić czytelność zapytania (wiadomo, która zmienna jest w którym miejscu), lecz w PDO mają ograniczenie – nie można użyć dwa razy tej samej nazwy parametru w jednym zapytaniu, chyba że użyjemy emulacji (to detal, na który rzadko natknie się początkujący). Z kolei w MySQLi nie ma nazwanych parametrów – parametry są łączone z miejscami?
w kolejności. - Obsługa błędów: MySQLi domyślnie nie wyrzuca wyjątków, a jedynie sygnalizuje błędy poprzez kod i komunikat błędu. Trzeba pisać dodatkowy kod sprawdzający
$mysqli->error
lub wartości zwracane. Istnieje możliwość włączenia trybu wyjątków (poprzezmysqli_report()
lub ustawienia konfiguracji), ale nie jest to standard zachowania. PDO natomiast oferuje łatwe przełączanie trybu błędów – możemy odbierać błędy jako ciche (ERRMODE_SILENT
), jako ostrzeżenia (ERRMODE_WARNING
), lub jako wyjątki (ERRMODE_EXCEPTION
). Ustawienie na wyjątki (co zrobiliśmy wcześniej) jest bardzo wygodne, bo pozwala użyć mechanizmu try/catch, co upraszcza kontrolę przepływu w przypadku błędów. - Funkcjonalności dodatkowe: MySQLi posiada pewne funkcje i cechy specyficzne dla MySQL, których PDO może nie wspierać bezpośrednio. Przykłady to:
- Asynchroniczne zapytania – MySQLi oferuje mechanizm wykonywania zapytań asynchronicznie (funkcje
mysqli_poll
, flagiMYSQLI_ASYNC
itp.), co bywa przydatne w zaawansowanych przypadkach, gdy chcemy równolegle wykonać kilka zapytań. PDO takiego mechanizmu nie udostępnia. - Multi-query – MySQLi pozwala wykonać wiele zapytań naraz w jednym stringu SQL (
$conn->multi_query("STATEMENT1; STATEMENT2;")
). W PDO standardowo pojedyncze wywołanie dotyczy jednego zapytania; aby wykonać wiele na raz, musielibyśmy włączyć tryb emulacji i przesłać kilka poleceń po średniku, co nie zawsze jest zalecane. W praktyce jednak wielokrotne zapytania można zwykle wykonać kolejno, a multi-query bywa rzadko używane. - Pobieranie dodatkowych informacji o zapytaniu – MySQLi udostępnia np. metodę
$conn->info
czy funkcje do uzyskania liczby ostrzeżeń na serwerze, itp. PDO skupia się raczej na przenośnych aspektach i czasem dostęp do specyficznych informacji MySQL może wymagać wywołania zapytania SQL (np.SHOW WARNINGS
). - Zamykanie połączenia – MySQLi oferuje metodę
$conn->close()
, podczas gdy PDO nie ma dedykowanej metodyclose()
. Jednak w PDO zamknięcie połączenia następuje automatycznie przy usunięciu obiektu (wystarczy$pdo = null
). Nie jest to więc brak funkcjonalności, a jedynie inny sposób zarządzania zasobami.
- Asynchroniczne zapytania – MySQLi oferuje mechanizm wykonywania zapytań asynchronicznie (funkcje
- Wydajność: Czysto wydajnościowo MySQLi i PDO są do siebie zbliżone. Różnice w szybkości wykonania zapytań są zwykle pomijalne dla typowych zastosowań. Czasem pojawiają się dyskusje, czy jedna biblioteka jest szybsza od drugiej – testy wydajności pokazują minimalne różnice, które mogą się zmieniać w zależności od wersji PHP, sterowników czy sposobu użycia. Decyzji nie powinno się raczej opierać na wydajności, a na powyższych cechach (np. przenośność vs specyficzne funkcje).
Podsumowując, PDO jest często polecane jako domyślny wybór, zwłaszcza dla nowych projektów, ze względu na wszechstronność i wygodną obsługę błędów. MySQLi jednak również jest w pełni poprawnym wyborem, szczególnie jeśli wiemy, że nasza aplikacja zawsze będzie używać MySQL i chcemy skorzystać z jego najnowszych możliwości. W wielu prostych zastosowaniach obie biblioteki sprawdzą się równie dobrze – ich składnia jest bardzo podobna (co widać było w przykładach), więc przesiadka z jednej na drugą nie jest trudna. Należy unikać jedynie mieszania obu na raz – wybierzmy jedną i konsekwentnie się jej trzymajmy w projekcie.
Obsługa błędów i wyjątki
Błędy w pracy z bazą danych są nieuniknione – mogą wynikać z literówek w SQL, naruszenia ograniczeń (np. próba dodania rekordu z kluczem, który już istnieje), utraty połączenia z serwerem i wielu innych przyczyn. Dlatego ważne jest, aby kod potrafił te błędy wykryć i odpowiednio obsłużyć, zamiast po prostu przerywać działanie lub, co gorsza, kontynuować z błędnymi założeniami.
MySQLi – sprawdzanie błędów: Jak wcześniej wspomniano, MySQLi nie rzuca wyjątków domyślnie. Sposób obsługi błędów polega na sprawdzaniu wartości zwracanej przez funkcje/metody oraz właściwości błędu. Kilka wskazówek:
- Po wykonaniu
$conn->query($sql)
warto sprawdzić, czy wynik nie jestFALSE
. Jeżeli jest, oznacza to błąd wykonania zapytania. Informacja o błędzie jest dostępna pod$conn->error
(wiadomość tekstowa) oraz$conn->errno
(kod błędu numeryczny). Przykład:
$result = $conn->query($sql);
if ($result === FALSE) {
echo "Błąd zapytania: " . $conn->error . " (kod " . $conn->errno . ")";
}
Przy próbie połączenia, jak widzieliśmy, można sprawdzać $conn->connect_error
lub użyć funkcji proceduralnej mysqli_connect_error()
. Jeśli połączenie się nie uda, nie ma sensu wykonywać dalszych zapytań – należy np. przerwać skrypt lub podjąć próbę ponownego połączenia po pewnym czasie (w aplikacjach produkcyjnych można zaimplementować np. kilkukrotne ponawianie połączenia zanim zgłosi się krytyczny błąd).
- Wyjątki w MySQLi: Można włączyć tryb zgłaszania wyjątków przez MySQLi wywołując funkcję
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
przed utworzeniem połączenia. Wówczas błędy będą rzucane jako wyjątki typumysqli_sql_exception
. Przykład:
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
try {
$conn = new mysqli($host, $user, $password, $database);
$conn->set_charset("utf8mb4"); // ... wykonanie zapytań ...
} catch (mysqli_sql_exception $e) {
echo "Wystąpił błąd bazy danych: " . $e->getMessage();
}
Takie podejście z try/catch w MySQLi upodabnia się do stylu PDO. Wybór trybu zależy od preferencji – wielu programistów trzyma się domyślnego sposobu (sprawdzanie błędów ręcznie), ale w większych projektach tryb wyjątków bywa wygodny.
PDO – obsługa wyjątków: Jeśli ustawimy PDO::ATTR_ERRMODE
na PDO::ERRMODE_EXCEPTION
(co zrobiliśmy przy połączeniu), to w zasadzie mamy prostą sprawę – każda nieudana operacja na bazie danych spowoduje rzucenie wyjątku PDOException
. Możemy zatem opakować krytyczne operacje w blok try-catch
:
try {
$pdo->query("BAD SQL SYNTAX"); // to zapytanie zawiera błąd składniowy
} catch (PDOException $e) {
echo "SQL Error: " . $e->getMessage();
}
Jeśli nie przechwycimy takiego wyjątku, a błąd wystąpi, skrypt zakończy się błędem (fatal error). W trakcie developmentu może to być zauważalne od razu, ale na produkcji nie chcemy takich „wyjątków nieobsłużonych”. Dlatego można zastosować globalny mechanizm przechwytywania wyjątków za pomocą set_exception_handler()
, który przekieruje nieobsłużone wyjątki do specjalnej funkcji (np. logującej je i wyświetlającej przyjazny komunikat). PDOException, jak każdy wyjątek, może być też obsłużony w miejscu wystąpienia jak pokazano wyżej.
Nie ujawniaj szczegółów błędów użytkownikom: Bardzo ważna zasada – komunikaty błędów z bazy danych często zdradzają wewnętrzne szczegóły aplikacji lub zapytań SQL. W środowisku produkcyjnym nie należy ich wyświetlać bezpośrednio użytkownikom. Zamiast tego:
- Można wyświetlić ogólny komunikat typu „Wystąpił błąd podczas operacji. Spróbuj ponownie później.” bez szczegółów.
- Szczegóły techniczne błędu (np. pełny tekst zapytania, kod błędu, stack trace) powinny trafić do logów serwera, do których ma dostęp tylko deweloper/administrator.
- Upewnij się, że w konfiguracji PHP opcja
display_errors
jest wyłączona (0
) na serwerze produkcyjnym, aby ewentualne niezłapane wyjątki nie wypisały się użytkownikowi wraz ze ścieżkami plików czy danymi połączenia. Zamiast tego stosujlog_errors
włączone i zapis błędów do pliku dziennika.
Transakcje a błędy: Jeśli Twoja aplikacja używa transakcji (np. poprzez $conn->begin_transaction()
w MySQLi lub $pdo->beginTransaction()
w PDO), obsługa błędów powinna uwzględniać wycofanie transakcji w razie problemów. Przykładowo:
$pdo->beginTransaction();
try {
// kilka zapytań, np. przeniesienie środków między kontami
$pdo->exec($sql1);
$pdo->exec($sql2);
$pdo->commit();
} catch (PDOException $e) {
$pdo->rollBack();
echo "Transakcja przerwana, dokonano rollback. Błąd: " . $e->getMessage();
}
Podobnie w MySQLi, po $conn->begin_transaction()
w razie błędu należy wywołać $conn->rollback()
. W ten sposób dane w bazie pozostaną spójne nawet, gdy część operacji się nie powiedzie.
Reasumując: zawsze sprawdzaj rezultaty operacji na bazie, używaj wyjątków lub instrukcji warunkowych do wychwycenia błędów i nigdy nie zakładaj, że dane wejściowe lub zapytania zawsze będą poprawne. Dobrze zaprojektowany kod potrafi obsłużyć błędne sytuacje w kontrolowany sposób.
Prepared statements (instrukcje przygotowane) – składnia, zastosowanie, korzyści
Prepared statements (zapytania przygotowane, czasem tłumaczone jako instrukcje parametryzowane) to mechanizm pozwalający oddzielić definicję zapytania SQL od danych, które do niego wstawiamy. Umożliwia to wielokrotne wykonanie tego samego zapytania z różnymi danymi w sposób bezpieczny i efektywny. W praktyce przygotowane zapytania rozwiązują problem SQL injection oraz mogą poprawić wydajność, gdy to samo zapytanie wykonujemy wielokrotnie.
Jak to działa? W pierwszym etapie wysyłamy do serwera bazodanowego szablon zapytania z pustymi miejscami na dane (tzw. placeholdery). Serwer analizuje i kompiluje zapytanie (ale go nie wykonuje), przygotowując miejsce na późniejsze podstawienie zmiennych. W drugim etapie przesyłamy konkretne wartości zmiennych i wydajemy polecenie wykonania wcześniej przygotowanego zapytaniaphp.net. Dzięki temu serwer wie, że przekazane wartości są danymi, a nie częścią kodu SQL, co chroni przed wstrzyknięciem złośliwego kodu. Ponadto, jeśli to zapytanie wykonujemy np. w pętli dla wielu różnych zestawów danych, baza nie musi za każdym razem kompilować SQL – może wykorzystać raz przygotowany plan wykonania, co oszczędza czas.
Zarówno MySQLi, jak i PDO obsługują ten tryb pracy. Spójrzmy na przykłady.
- Prepared statements w MySQLi (obiektowo):
// Przygotowanie zapytania z dwoma parametrami (imię i email)
$stmt = $conn->prepare("INSERT INTO users (name, email) VALUES (?, ?)");
if (!$stmt) {
die("Błąd przygotowania zapytania: " . $conn->error);
}
// Powiązanie zmiennych z parametrami zapytania
$stmt->bind_param("ss", $name, $email); // "ss" oznacza dwa parametry typu string (string, string)
// Przypisanie wartości do zmiennych i wykonanie
$name = "Janek";
$email = "janek@example.com";
$stmt->execute(); // Ponowne wykorzystanie tego samego przygotowanego zapytania z innymi danymi:
$name = "Kasia";
$email = "kasia@example.com";
$stmt->execute();
$stmt->close();
W powyższym kodzie najpierw wywołujemy $conn->prepare()
z podanym szablonem zapytania SQL. W miejscu, gdzie normalnie pojawiłyby się wartości, wstawiliśmy znak zapytania ?
– to właśnie placeholder. Funkcja prepare()
zwraca obiekt mysqli_stmt
(przygotowane zapytanie) lub FALSE, jeśli nastąpił błąd podczas przygotowania (np. złe SQL). Gdy mamy $stmt
, wywołujemy $stmt->bind_param()
, przekazując jako pierwszy argument łańcuch określający typy danych kolejnych parametrów. W naszym przypadku "ss"
oznacza, że pierwszym parametrem będzie string (s), i drugim również string (s). Gdybyśmy przekazywali np. liczbę całkowitą i string, użylibyśmy "is"
(i – integer, s – string). Dostępne oznaczenia to: i
(int), d
(double – liczba zmiennoprzecinkowa), s
(string), b
(blob, dane binarne). Następne argumenty bind_param
to zmienne PHP, które mają zostać powiązane z parametrami. Ważne: wiążemy zmienne, nie konkretną wartość. Oznacza to, że jeśli później zmienimy wartość tych zmiennych i ponownie wykonamy zapytanie, to zostaną użyte nowe wartości. Powyżej widać to w akcji: najpierw przypisujemy "Janek", "janek@example.com"
i wywołujemy $stmt->execute()
, co spowoduje wstawienie tych danych do zapytania i dodanie rekordu do bazy. Następnie ustawiamy zmienne $name
i $email
na inne wartości i znów wywołujemy $stmt->execute()
. To doda kolejny rekord, tym razem z imieniem Kasia. Nie trzeba ponownie wywoływać prepare()
, a nawet bind_param()
(chyba że chcemy powiązać inne zmienne lub zmienić typy) – możemy wielokrotnie wykonywać execute()
z różnymi wartościami wcześniej powiązanych zmiennych. Po zakończeniu pracy z przygotowanym zapytaniem, wywołujemy $stmt->close()
aby zwolnić zasoby związane z tym zapytaniem. Oczywiście prepared statements można użyć także do zapytań SELECT. Wtedy po execute()
należałoby pobrać wyniki. Można to zrobić na dwa sposoby: 1. Z użyciem metody $stmt->get_result()
, która zwróci obiekt wynikowy podobny do tego z $conn->query()
. Następnie używamy fetch_assoc()
na tym wyniku. Uwaga: get_result()
wymaga, aby PHP był skompilowany z natywnym sterownikiem MySQLND (większość współczesnych instalacji PHP go ma). 2. Z użyciem bind_result()
i fetch()
– wiązanie zmiennych do kolumn wyniku. To bardziej niskopoziomowe podejście: trzeba znać liczbę i typy kolumn, wywołać $stmt->bind_result($col1, $col2, ...)
, a następnie $stmt->fetch()
, który ustawi te zmienne. W prostych zastosowaniach łatwiej jest skorzystać z get_result()
.
- Prepared statements w PDO:
// Przygotowanie zapytania SELECT z nazwanym parametrem :email
$stmt = $pdo->prepare("SELECT * FROM users WHERE email = :email");
// Powiązanie wartości parametru i wykonanie
$emailParam = "janek@example.com";
$stmt->execute(['email' => $emailParam]);
// Pobranie wyniku
$user = $stmt->fetch(PDO::FETCH_ASSOC);
echo "Znaleziono użytkownika: " . $user['name'];
$stmt->closeCursor();
Tutaj widzimy przygotowanie zapytania z użyciem nazwanego parametru :email
. Metoda $pdo->prepare()
zwraca obiekt PDOStatement lub rzuca wyjątek w razie poważnego błędu (np. składni SQL). Zakładamy, że się powiodło. Aby przekazać wartość do parametru, używamy metody $stmt->execute()
. Można to zrobić na dwa sposoby w PDO: – Przekazując tablicę wartości – w formie indeksowanej (jeśli używaliśmy ?
) lub asocjacyjnej (gdy używamy nazwanych parametrów). W powyższym przykładzie przekazujemy tablicę ['email' => $emailParam]
. Klucz 'email'
odpowiada nazwie parametru bez dwukropka (PDO automatycznie dopasuje to do :email
w zapytaniu). To podejście jest szybkie i wygodne, bo łączy w sobie wiązanie i wykonanie w jednym kroku. – Alternatywnie można najpierw wywołać $stmt->bindParam(':email', $emailParam)
osobno, a potem samo $stmt->execute()
bez argumentów. bindParam
w PDO pozwala też opcjonalnie określić typ danych (trzeci argument, np. PDO::PARAM_STR
dla string, PDO::PARAM_INT
dla integer itd.), chociaż PDO zazwyczaj sam dobrze rozpoznaje typ. Po wykonaniu zapytania SELECT, wynik jest gotowy do pobrania z $stmt
. Możemy użyć $stmt->fetch()
aby pobrać pojedynczy wiersz (jak zrobiliśmy powyżej, zakładając że email jest unikalny i dostaniemy co najwyżej jednego użytkownika), albo użyć pętli tak jak w poprzednich przykładach. Wykorzystujemy tryb FETCH_ASSOC by otrzymać tablicę asocjacyjną. Jeśli planujemy ponownie użyć tego PDOStatement do innego zapytania lub chcemy eksplicytnie zwolnić połączenie do serwera (w przypadku dużych wyników), można wywołać $stmt->closeCursor()
, co zwolni serwer do obsługi innych zapytań na danym połączeniu. Korzyści i uwagi: Widzimy, że prepared statements w PDO są nieco prostsze w użyciu – nie trzeba podawać typów (jak "ss"
w MySQLi), zazwyczaj nie trzeba wcześniej wiązać (można od razu przekazać tablicę do execute). Dodatkowo możliwość użycia nazwanych parametrów może poprawić czytelność zapytań, zwłaszcza jeśli jest ich wiele. Najważniejszą zaletą jest jednak bezpieczeństwo: niezależnie od tego, co znajduje się w zmiennej $emailParam
, zostanie to przekazane do bazy w kontrolowany sposób jako wartość parametru, a nie część kodu. Oznacza to, że nawet gdyby w adresie e-mail ktoś próbował przemycić np. "' OR 1=1--"
(klasyczny trik SQL injection), to serwer potraktuje to jako zwykły ciąg znaków (wartość szukanego email), a nie fragment zapytania. Innymi słowy, stosując prepared statements, zabezpieczamy się przed atakami SQL injection. Warto wspomnieć, że prepared statements mogą też minimalnie poprawić wydajność przy wielokrotnym wykonywaniu tego samego zapytania. Jak pokazano w przykładzie MySQLi – przygotowaliśmy raz zapytanie INSERT
i wykonaliśmy je dwa razy z różnymi danymi. Przy pierwszym wykonaniu baza danych skompilowała zapytanie, przy drugim mogła już skorzystać z wcześniejszego przygotowania, stosując tylko nowe wartości. Gdybyśmy to samo zrobili tradycyjnie, dwa razy musiałaby przetworzyć całe zapytanie od zera. Różnica przy dwóch wykonaniach jest pomijalna, ale przy setkach czy tysiącach powtórzeń może być odczuwalna (np. wstawianie wielu rekordów w pętli). Należy również wiedzieć, że PDO posiada mechanizm emulacji przygotowanych zapytań. Oznacza to, że w pewnych sytuacjach zapytanie nie jest przygotowywane po stronie serwera, tylko PDO sam podstawia wartości lokalnie i wykonuje zwykłe zapytanie. Domyślnie emulacja jest włączona dla niektórych sterowników, ale dla MySQL od lat domyślnie PDO używa prawdziwych prepared statements (można to zmienić atrybutem PDO::ATTR_EMULATE_PREPARES
). W kontekście bezpieczeństwa emulowane i natywne prepared statements dają ten sam poziom ochrony przed SQL injection, jednak w szczególnych przypadkach emulacja może mieć wpływ na zachowanie (np. obsługa bardzo dużych danych typu BLOB). Dla typowych zastosowań nie musimy się tym przejmować.
Podsumowanie: Korzystanie z prepared statements jest zdecydowanie zalecane przy pracy z bazą danych. Jak stwierdza oficjalna dokumentacja PHP: „A prepared statement … is used to execute the same statement repeatedly with high efficiency and protect against SQL injections.”. Stosując ten mechanizm zyskujemy zarówno na bezpieczeństwie, jak i na czystości kodu (oddzielenie logiki SQL od danych). W kolejnej sekcji przyjrzymy się bliżej kwestii bezpieczeństwa i innym środkom ochrony.
Bezpieczeństwo – ochrona przed SQL injection i filtrowanie danych wejściowych
SQL injection (wstrzyknięcie SQL) to jedno z najpoważniejszych zagrożeń czyhających na aplikacje bazodanowe. Polega ono na takim manipulowaniu danymi wejściowymi aplikacji, by zmusić ją do wykonania niezamierzonego przez programistę zapytania SQL. Atakujący wstrzykuje do przesyłanych danych fragmenty kodu SQL. Jeśli aplikacja nie jest zabezpieczona i po prostu dokleja te dane do zapytań, baza danych może wykonać np. polecenie usunięcia tabeli lub ujawnienia danych, do których nie powinno być dostępu. W skrajnych przypadkach „SQL injection może zniszczyć bazę danych”. Jest to jedna z najczęstszych technik ataków na aplikacje webowe.
Przykład ataku: Wyobraźmy sobie formularz logowania, gdzie użytkownik podaje login i hasło. Na serwerze (błędnie) budowane jest zapytanie w stylu:
$username = $_POST['user'];
$password = $_POST['pass'];
$sql = "SELECT * FROM users WHERE username='$username' AND password='$password'";
Jeśli intruz w polu użytkownika wpisze: admin' --
a hasło zostawi puste, to zapytanie stanie się:
SELECT * FROM users WHERE username='admin' --' AND password=''
Znak --
w SQL oznacza początek komentarza, więc faktyczne zapytanie, jakie wykona baza, to:
SELECT * FROM users WHERE username='admin'
Hasło zostało skomentowane, czyli warunek hasła został pominięty! W efekcie atakujący zaloguje się jako „admin” nie znając hasła. To prosty przykład, ale pokazuje skalę problemu. W innym scenariuszu ktoś mógłby wstrzyknąć '; DROP TABLE users; --
co spowodowałoby usunięcie tabeli użytkowników z bazy.
Obrona przed SQL injection: Najskuteczniejszą obroną jest używanie przygotowanych zapytań (omówionych wyżej). Kiedy stosujemy parametryzowane zapytania, dane użytkownika nie są łączone z kodem SQL bezpośrednio, tylko przekazywane oddzielnie, co uniemożliwia interpretację tych danych jako polecenia. Nawet jeśli w nazwie użytkownika będzie admin' --
, to serwer potraktuje to jako zwykły ciąg znaków do porównania, a nie jako część składni.
Inne dobre praktyki zabezpieczające przed SQL injection i innymi zagrożeniami związanymi z danymi wejściowymi:
- Walidacja i filtrowanie danych wejściowych: Zawsze sprawdzajmy, czy dane od użytkownika mają spodziewany format i zakres. Np. jeśli oczekujemy liczby (ID, wiek itp.), upewnijmy się że to faktycznie liczba (np. używając
ctype_digit
, rzutowania(int)
lub filtraFILTER_VALIDATE_INT
). Jeżeli oczekujemy adresu email – możemy skorzystać zfilter_var($email, FILTER_VALIDATE_EMAIL)
by sprawdzić poprawność. Filtrowanie może też polegać na usuwaniu niedozwolonych znaków (np. jeśli spodziewamy się tylko liter, odrzucamy wszystko inne). - Escapowanie znaków specjalnych: Jeżeli z jakiegoś powodu musimy jednak wbudować zmienną w zapytanie SQL (co powinno być ostatecznością), należy koniecznie użyć funkcji ucieczki znaków. Dla MySQLi będzie to
mysqli_real_escape_string($conn, $zmienna)
, a dla PDO można użyć$pdo->quote($zmienna)
lub funkcji PDO::quote (choć lepiej jednak przejść na prepared statements). Funkcje te poprzedzą specjalne znaki (jak cudzysłów, apostrof, backslash) odpowiednimi znakami ucieczki, co zapobiega złamaniu składni SQL. Samo escapowanie jednak bywa zawodne, jeśli zapomnimy go użyć w jednym miejscu – dlatego parametryzacja jest bezpieczniejsza, bo wymusza rozdział danych. - Ograniczanie uprawnień w bazie: To nie dotyczy bezpośrednio kodowania w PHP, ale jest ważną częścią bezpieczeństwa. Konto bazy danych, którego używa nasza aplikacja, powinno mieć minimalne potrzebne uprawnienia. Np. jeśli aplikacja tylko odczytuje dane, użyjmy użytkownika z prawami SELECT (bez INSERT/UPDATE/DELETE/DROP). Jeśli używamy kilku baz, dajmy uprawnienia tylko do niezbędnych. W ten sposób, nawet jeśli dojdzie do ataku, szkody mogą być ograniczone brakiem uprawnień do destrukcyjnych operacji.
- Bezpieczne przechowywanie danych w bazie: Choć to trochę obok tematu SQL injection, na bezpieczeństwo składa się też to, co trzymamy w bazie. Na przykład hasła użytkowników nigdy nie powinny być przechowywane w postaci czystej (plaintext). Zamiast tego należy je hashować z użyciem soli i bezpiecznego algorytmu, zanim zapiszemy w bazie. PHP oferuje funkcję
password_hash()
orazpassword_verify()
, które ułatwiają to zadanie – przykład podamy za chwilę w sekcji dobrych praktyk. - Filtrowanie danych wyjściowych: To już nie SQL injection, ale warto wspomnieć – jeśli wyświetlamy dane z bazy na stronie, należy je zabezpieczyć przed atakami XSS (Cross-Site Scripting) poprzez escapowanie w kontekście HTML (np.
htmlspecialchars()
w PHP). Przykładowo, jeśli ktoś wprowadzi w formularzu imię<script>alert('xss')</script>
i zapiszemy to w bazie, to potem wyświetlając imię bez zabezpieczeń, wykonamy złośliwy skrypt na stronie. Dlatego każdą daną wyświetlaną w HTML powinniśmy przepuścić przezhtmlspecialchars($dana, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
. To nie dotyczy bezpośrednio połączenia z bazą, ale jest częścią ogólnego bezpieczeństwa aplikacji webowej.
Podsumowując: Nigdy nie ufaj danym wejściowym. Zabezpiecz zapytania SQL stosując parametry zamiast składania stringów, a dodatkowo waliduj dane po stronie aplikacji. Dzięki temu ryzyko udanego ataku SQL injection spada do zera, a Twoja baza i dane użytkowników pozostaną bezpieczne.
Dobre praktyki przy pracy z bazą danych w PHP
Na koniec omówmy kilka dobrych praktyk, które warto stosować, tworząc aplikacje PHP korzystające z baz danych. Dotyczą one zarówno kwestii technicznych (jak gospodarowanie zasobami połączenia), organizacji kodu, jak i bezpieczeństwa w szerszym ujęciu.
Zamykanie połączenia z bazą
W języku PHP połączenie z bazą danych zostanie automatycznie zamknięte po zakończeniu działania skryptu (np. po wysłaniu strony do przeglądarki). Oznacza to, że w prostej aplikacji internetowej zazwyczaj nie musimy ręcznie zamykać połączenia – PHP posprząta za nas. Niemniej jednak, są sytuacje, w których warto to zrobić samodzielnie:
- Jeśli skrypt kończy pracę wcześniej (np. po obsłużeniu żądania AJAX) i wiemy, że połączenie nie będzie już potrzebne, można je zamknąć zanim zakończy się cały proces PHP. Zwolni to zasoby po stronie serwera bazy nieco szybciej.
- W przypadku skryptów CLI (uruchamianych z linii poleceń) lub długotrwałych procesów, które mogą wykonywać wiele różnych zadań sekwencyjnie, warto zamykać połączenie gdy nie jest potrzebne, aby nie utrzymywać niepotrzebnie otwartego połączenia.
Jak zamknąć połączenie? W MySQLi (obiektowym) wywołujemy metodę $conn->close()
, w stylu proceduralnym funkcję mysqli_close($conn)
. W PDO nie ma dedykowanej metody, ale wystarczy usunąć obiekt PDO przez przypisanie go do null
:
$conn->close(); // MySQLi obiektowo
// mysqli_close($conn); // MySQLi proceduralnie
$pdo = null; // PDO
Powyższe operacje zamkną połączenie natychmiast. Warto też upewnić się, że zamykamy wszelkie otwarte obiekty wyników (result sets) czy kursory przed zamknięciem połączenia, choć w większości przypadków PHP to obsłuży.
Struktura i organizacja kodu
Dbanie o czytelność i modularność kodu jest ważne szczególnie w większych projektach. Kilka wskazówek:
- Oddzielenie konfiguracji połączenia: Zamiast wpisywać na sztywno dane dostępowe (host, użytkownik, hasło, nazwa bazy) w wielu plikach, trzymajmy je w jednym miejscu. Może to być plik konfiguracyjny (np. PHP zwracający tablicę config) albo – bezpieczniej – plik poza repozytorium (jeśli kod jest wersjonowany publicznie). Często używa się też zmiennych środowiskowych (env) do przechowywania haseł. Dzięki temu łatwo zmienić np. hasło dostępu w jednym miejscu bez szukania w całym kodzie.
- Reużywanie połączenia: Unikajmy tworzenia wielu połączeń do bazy w ramach jednego żądania, jeśli nie jest to potrzebne. Zazwyczaj wystarczy jedno połączenie na początku skryptu, które przekazujemy (np. przez zmienną globalną czy jako parametr do funkcji/obiektów). Otwarcie połączenia jest stosunkowo kosztowne, więc nie róbmy tego w pętli lub w wielu miejscach niezależnie.
- Funkcje/klasy do operacji bazodanowych: Gdy nasz kod zaczyna intensywnie korzystać z bazy, warto wydzielić obsługę bazy danych do oddzielnych funkcji lub metod klas. Na przykład, zamiast wszędzie powtarzać zapytanie SELECT użytkownika po ID, można napisać funkcję
getUserById($id)
wewnątrz jakiejś klasyUserModel
lub w oddzielnym pliku. Taka funkcja może wewnątrz korzystać z$pdo
czy$conn
, ale dla reszty kodu jest czarną skrzynką – reszta kodu woła tylkogetUserById(5)
i dostaje wynik, nie musząc znać szczegółów zapytania. To realizuje zasadę DRY (Don’t Repeat Yourself) i poprawia czytelność. - Rozdzielenie logiki aplikacji od logiki dostępu do danych: W profesjonalnych projektach często stosuje się architekturę warstwową (np. MVC – Model-View-Controller). Model odpowiada za operacje na danych (w tym na bazie), View za prezentację (HTML), a Controller za łączenie jednego z drugim. Starajmy się nie mieszać mocno zapytań SQL z kodem HTML generującym widok. Jeśli np. generujemy tabelkę HTML z listą produktów, to najpierw w jednym miejscu pobierzmy listę produktów z bazy (np. wywołując metodę modelu), a potem przekazujemy tę listę do części odpowiedzialnej za wyświetlanie. Takie rozdzielenie ułatwia utrzymanie kodu – zmiany w strukturze bazy wymagają zmiany tylko w warstwie modelu, a zmiany w wyglądzie tabelki tylko w warstwie widoku.
- Nazewnictwo i czytelność: Nazywaj zmienne i funkcje w sposób zrozumiały. Zamiast
$r = $c->query($q)
lepiej$result = $conn->query($sql)
. Używaj komentarzy do wyjaśnienia nietrywialnych fragmentów. W przykładach stosowaliśmy komentarze dla czytelności (np.// Ustawienie trybu błędów na wyjątki
).
Bezpieczne przechowywanie haseł
Kwestia przechowywania haseł dotyczy aplikacji, które mają system logowania/autoryzacji użytkowników (np. konta klientów sklepu internetowego). Nigdy nie przechowujemy haseł w postaci jawnej w bazie danych! Jeśli baza by wyciekła lub ktoś uzyskał do niej dostęp, nasze hasła użytkowników znalazłyby się na widelcu. Zamiast tego stosujemy funkcje skrótu (hash) – jednokierunkowego przekształcania hasła na inny ciąg znaków. PHP dostarcza gotowe, bezpieczne mechanizmy do haszowania haseł:
- Funkcja
password_hash($haslo, PASSWORD_DEFAULT)
zwraca zaszyfrowany (ściślej: zahaszowany) odpowiednik podanego hasła, wykorzystując algorytm domyślny rekomendowany na dany moment (aktualnie jest to bcrypt, a w nowych wersjach PHP może to być Argon2). Funkcja automatycznie dodaje losową sól (salt) do każdego hasła, co zabezpiecza przed atakami słownikowymi i tęczowymi tablicami. - Funkcja
password_verify($haslo_podane, $hash)
pozwala sprawdzić, czy podane przez użytkownika hasło zgadza się z wcześniej zapisanym hashem.
Przykład użycia:
<?php // Rejestracja użytkownika – haszowanie hasła przed zapisaniem
$plainPassword = "MojeSekretneHaslo123";
$hash = password_hash($plainPassword, PASSWORD_DEFAULT);
// $hash zawiera teraz np. coś w stylu: $2y$10$92IXUNpkjO... (ciąg ~60 znaków)
// Zapisz $hash do bazy zamiast oryginalnego hasła użytkownika
// Logowanie – weryfikacja hasła
$podaneHaslo = "MojeSekretneHaslo123"; // to zazwyczaj przyjdzie z formularza
if (password_verify($podaneHaslo, $hash)) {
echo "Hasło poprawne – użytkownik zalogowany.";
} else {
echo "Niepoprawne hasło.";
}
?>
Dzięki temu nawet jeśli ktoś wykradnie naszą bazę, nie pozna właściwych haseł – zobaczy jedynie hashe, których złamanie (czyli odgadnięcie oryginalnego hasła) jest zaprojektowane jako ekstremalnie trudne i czasochłonne, szczególnie gdy używamy silnych algorytmów z soleniem. Obecnie PASSWORD_DEFAULT
wykorzystuje bcrypt, który jest wystarczający dla większości zastosowań, ale PHP ma też stałą PASSWORD_ARGON2ID
pozwalającą użyć Argon2 (jeśli jest dostępny na serwerze), który jest uważany za bardzo bezpieczny. Dobrą praktyką jest także co pewien czas sprawdzać, czy algorytm domyślny się nie zmienił i ewentualnie rehashować stare hashe (PHP oferuje funkcję password_needs_rehash()
do tego celu).
Podsumowując: przechowuj w bazie wyłącznie hashe haseł, nigdy haseł wprost. Dotyczy to również innych wrażliwych danych – jeśli np. musisz przechować numer karty kredytowej, rozważ użycie szyfrowania kluczem, do którego klucz nie jest trzymany w tej samej bazie.
Rozdzielenie warstw logiki
Ten temat częściowo pokryliśmy w organizacji kodu, ale warto go podkreślić jako ogólną dobrą praktykę: rozdzielaj warstwę dostępu do danych od reszty aplikacji. Oznacza to:
- Logika biznesowa (reguły działania aplikacji, np. „użytkownik może kupić produkt tylko jeśli jest w magazynie”) powinna być pisana niezależnie od tego, jak dokładnie pobierane są dane. Może używać metod interfejsu do bazy, ale nie powinna zawierać np. surowych zapytań SQL.
- Warstwa dostępu do danych (DAO – Data Access Object, albo model w terminologii MVC) powinna udostępniać czyste funkcje/metody do pobierania lub modyfikowania danych. W jej implementacji będą zapytania SQL (lub wywołania ORM – Object-Relational Mapping, jeśli jest używany).
- Warstwa prezentacji (widok) nie powinna wykonywać zapytań do bazy. Powinna dostać już przygotowane dane od kontrolera/modelu i tylko je wyświetlić. Dzięki temu łatwo np. zmienić źródło danych (np. przenieść część logiki do cache) bez zmiany kodu widoku.
Na poziomie początkującym i średniozaawansowanym nie zawsze stosuje się od razu pełen MVC, ale nawet w prostych skryptach warto trzymać się zasady: SQL w jednym miejscu, HTML w innym. Unikajmy pisanego „spaghetti”, gdzie w połowie pętli generującej tabelę HTML nagle robimy zapytanie do bazy. Lepiej najpierw pobrać wszystko do tablicy, a potem iterować po tablicy generując HTML.
Dzięki rozdzieleniu warstw kod staje się czytelniejszy, łatwiejszy do testowania i rozbudowy. Przykładowo, jeśli za rok postanowimy zmienić bazę danych na inny system, wystarczy zmodyfikować warstwę dostępu do danych (lub nawet tylko ciąg połączenia w przypadku użycia PDO i standardowego SQL), a reszta aplikacji pozostanie nietknięta.
Podsumowanie
Wykorzystanie baz danych w PHP jest fundamentem dla budowy nowoczesnych aplikacji webowych, które muszą przechowywać i pobierać informacje w sposób trwały. Poznaliśmy dwie główne metody realizacji tego zadania: MySQLi i PDO, z ich różnicami, zaletami i ograniczeniami. Nauczyliśmy się, jak nawiązać połączenie z bazą, wykonać podstawowe operacje (SELECT, INSERT, UPDATE, DELETE), a następnie ulepszyć nasz kod poprzez zastosowanie zapytań z parametrami (prepared statements) w celu zwiększenia bezpieczeństwa i elastyczności. Omówiliśmy również, jak ważna jest obsługa błędów – tak, by aplikacja była odporna na nieprzewidziane sytuacje – oraz jak krytyczne jest zabezpieczenie się przed atakami SQL injection poprzez właściwe praktyki programistyczne.
Na koniec podkreśliliśmy znaczenie dobrych praktyk: od zamykania połączeń i poprawnego przechowywania haseł, po przemyślaną strukturę kodu i podział odpowiedzialności w aplikacji. Stosowanie tych zasad od początku ułatwi rozwijanie projektów w przyszłości i zapobiegnie wielu błędom.
Mając tę wiedzę, początkujący i średniozaawansowani programiści PHP powinni czuć się pewniej w tworzeniu aplikacji wykorzystujących bazy danych. Kluczem jest praktyka – warto samodzielnie napisać prosty skrypt łączący się z bazą, wypróbować zapytania, a następnie stopniowo wprowadzać przygotowane zapytania i inne omówione usprawnienia. Z czasem powyższe elementy staną się naturalną częścią waszego stylu kodowania, co zaowocuje bezpieczniejszym i bardziej niezawodnym oprogramowaniem. Powodzenia w kodowaniu!