23. PHP – Debugowanie i obsługa błędów


Debugowanie to proces znajdowania i naprawiania błędów w kodzie. To umiejętność, która odróżnia początkującego od doświadczonego programisty.

TaskQuest

Dodatek

Rodzaje błędów w PHP

1. Parse Errors (Błędy składni)

Błędy w składni kodu – PHP nie może nawet uruchomić skryptu.

<?php
// Błąd: brak średnika
echo "Hello World"

// Błąd: brak zamykającego nawiasu
if ($x > 5 {
    echo "OK";
}

// Błąd: brak zamykającego cudzysłowu
echo "Hello;
?>

Komunikat błędu:

Parse error: syntax error, unexpected end of file in script.php on line 3

2. Fatal Errors (Błędy krytyczne)

Błędy krytyczne przerywające wykonanie skryptu.

<?php
// Wywołanie nieistniejącej funkcji
nieistniejacaFunkcja();

// Przekroczenie limitu pamięci
$huge_array = array_fill(0, 10000000, 'data');

// Błąd require/include
require 'nieistniejacy_plik.php';
?>

Komunikat błędu:

Fatal error: Uncaught Error: Call to undefined function nieistniejacaFunkcja()

3. Warning (Ostrzeżenia)

Ostrzeżenia nie przerywają wykonania skryptu, ale wskazują na problemy.

<?php
// Otwarcie nieistniejącego pliku
$file = fopen('nieistniejacy.txt', 'r');

// Dzielenie przez zero
$result = 10 / 0;

// Użycie niezdefiniowanej zmiennej
echo $niezdefiniowana;
?>

Komunikat błędu:

Warning: fopen(nieistniejacy.txt): failed to open stream: No such file or directory
Warning: Division by zero

4. Notice (Notki)

Najmniej poważne komunikaty – sugestie ulepszeń.

<?php
// Użycie niezdefiniowanego klucza tablicy
$tab = ['a' => 1];
echo $tab['b'];

// Użycie niezdefiniowanej zmiennej
echo $x;

// Użycie niezdefiniowanej stałej
echo NIEISTNIEJACA_STALA;
?>

Komunikat błędu:

Notice: Undefined index: b
Notice: Undefined variable: x
Notice: Use of undefined constant NIEISTNIEJACA_STALA

5. Deprecated (Przestarzałe)

Informacje o przestarzałych funkcjach.

<?php
// Przestarzała funkcja
mysql_connect('localhost', 'root', ''); // Deprecated w PHP 5.5, usunięte w PHP 7.0

// Przestarzały sposób tworzenia obiektu
$date = new DateTime;
$date->setDate(2024, 13, 32); // Deprecated warning
?>

1. error_reporting() – Poziomy błędów

Konfiguracja raportowania błędów

<?php
// Wyświetl WSZYSTKIE błędy (rozwój)
error_reporting(E_ALL);

// Wyświetl wszystkie oprócz Notice
error_reporting(E_ALL & ~E_NOTICE);

// Wyświetl tylko błędy krytyczne
error_reporting(E_ERROR | E_WARNING | E_PARSE);

// Wyłącz wyświetlanie błędów (produkcja)
error_reporting(0);

// Sprawdź aktualny poziom
echo error_reporting(); // Zwraca liczbę (np. 32767 dla E_ALL)
?>

Poziomy błędów – kompletna lista

<?php
// Błędy krytyczne
E_ERROR             // Błędy krytyczne (fatal errors)
E_PARSE             // Błędy składni
E_CORE_ERROR        // Błędy w jądrze PHP
E_COMPILE_ERROR     // Błędy kompilacji

// Ostrzeżenia
E_WARNING           // Ostrzeżenia runtime
E_CORE_WARNING      // Ostrzeżenia jądra PHP
E_COMPILE_WARNING   // Ostrzeżenia kompilacji
E_USER_WARNING      // Własne ostrzeżenia (trigger_error)

// Notki i informacje
E_NOTICE            // Notki runtime
E_USER_NOTICE       // Własne notki
E_STRICT            // Sugestie najlepszych praktyk
E_DEPRECATED        // Przestarzałe funkcje
E_USER_DEPRECATED   // Własne deprecation

// Wszystkie
E_ALL               // Wszystkie błędy i ostrzeżenia
?>

Przykład – Różne konfiguracje dla środowisk

<?php
// config.php

// Wykryj środowisko
$environment = getenv('APP_ENV') ?: 'development';

if ($environment === 'development') {
    // ROZWÓJ - pokaż wszystkie błędy
    error_reporting(E_ALL);
    ini_set('display_errors', 1);
    ini_set('display_startup_errors', 1);
    
} elseif ($environment === 'staging') {
    // STAGING - pokaż błędy, ale loguj je
    error_reporting(E_ALL);
    ini_set('display_errors', 1);
    ini_set('log_errors', 1);
    ini_set('error_log', '/var/log/php/staging_errors.log');
    
} else {
    // PRODUKCJA - ukryj błędy, tylko loguj
    error_reporting(E_ALL);
    ini_set('display_errors', 0);
    ini_set('display_startup_errors', 0);
    ini_set('log_errors', 1);
    ini_set('error_log', '/var/log/php/production_errors.log');
}
?>

2. ini_set() – Konfiguracja PHP w runtime

Podstawowe ustawienia

<?php
// Wyświetlanie błędów
ini_set('display_errors', 1);           // Wyświetl błędy (0 = wyłącz)
ini_set('display_startup_errors', 1);   // Błędy przy starcie PHP

// Logowanie błędów
ini_set('log_errors', 1);               // Loguj błędy do pliku
ini_set('error_log', 'errors.log');     // Ścieżka do pliku logów

// Limity
ini_set('memory_limit', '256M');        // Limit pamięci
ini_set('max_execution_time', 60);      // Czas wykonania (sekundy)
ini_set('upload_max_filesize', '10M');  // Max rozmiar uploadu

// Inne
ini_set('default_charset', 'UTF-8');
ini_set('date.timezone', 'Europe/Warsaw');

// Sprawdź wartość ustawienia
echo ini_get('memory_limit'); // 256M
?>

Przykład – Kompleksowa konfiguracja rozwojowa

<?php
// dev_config.php - Konfiguracja dla rozwoju

// Błędy
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);

// Logowanie
ini_set('log_errors', 1);
ini_set('error_log', __DIR__ . '/logs/php_errors.log');

// Limity (luźne dla rozwoju)
ini_set('memory_limit', '512M');
ini_set('max_execution_time', 300);
ini_set('max_input_time', 300);
ini_set('post_max_size', '50M');
ini_set('upload_max_filesize', '50M');

// Sesje
ini_set('session.cookie_httponly', 1);
ini_set('session.use_strict_mode', 1);

// Output buffering (przydatne przy debugowaniu)
ini_set('output_buffering', 'Off');
ini_set('implicit_flush', 1);

// Pokaż szczegóły SQL (tylko dla developmentu!)
ini_set('mysqli.allow_local_infile', 1);

echo "Konfiguracja deweloperska załadowana<br>";
echo "Memory limit: " . ini_get('memory_limit') . "<br>";
echo "Max execution time: " . ini_get('max_execution_time') . "s<br>";
?>

3. trigger_error() – Własne błędy

Wywoływanie własnych błędów

<?php
function divide($a, $b) {
    
    if ($b == 0) {
        // Wywołaj własny błąd
        trigger_error("Dzielenie przez zero!", E_USER_WARNING);
        return false;
    }
    
    return $a / $b;
}

$result = divide(10, 0);

// Bardziej szczegółowe błędy
function processUser($user_id) {
    
    if (!is_int($user_id)) {
        trigger_error("User ID musi być liczbą całkowitą, podano: " . gettype($user_id), E_USER_ERROR);
    }
    
    if ($user_id < 1) {
        trigger_error("User ID musi być dodatni, podano: $user_id", E_USER_WARNING);
        return false;
    }
    
    // Przestarzałe użycie
    if (func_num_args() > 1) {
        trigger_error("Funkcja processUser() przyjmuje tylko jeden argument. Wiele argumentów jest przestarzałe.", E_USER_DEPRECATED);
    }
    
    return "Processing user $user_id";
}

processUser(-5);        // Warning
processUser("abc");     // Fatal Error
processUser(1, 2);      // Deprecated
?>

Przykład – Walidacja z trigger_error()

<?php
class Validator {
    
    public static function validateEmail($email) {
        if (empty($email)) {
            trigger_error("Email nie może być pusty", E_USER_NOTICE);
            return false;
        }
        
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            trigger_error("Nieprawidłowy format email: $email", E_USER_WARNING);
            return false;
        }
        
        return true;
    }
    
    public static function validateAge($age) {
        if (!is_numeric($age)) {
            trigger_error("Wiek musi być liczbą, podano: " . gettype($age), E_USER_ERROR);
        }
        
        if ($age < 0) {
            trigger_error("Wiek nie może być ujemny: $age", E_USER_WARNING);
            return false;
        }
        
        if ($age > 150) {
            trigger_error("Nieprawdopodobny wiek: $age", E_USER_WARNING);
            return false;
        }
        
        return true;
    }
}

// Testy
Validator::validateEmail('');                    // Notice
Validator::validateEmail('invalid-email');       // Warning
Validator::validateAge('abc');                   // Fatal Error
Validator::validateAge(-5);                      // Warning
Validator::validateAge(200);                     // Warning
?>

4. error_log() – Logowanie błędów

Podstawowe użycie

<?php
// Loguj do domyślnego pliku error_log
error_log("To jest testowa wiadomość w logu");

// Loguj zmienną
$user_id = 123;
error_log("User ID: $user_id");

// Loguj tablicę (trzeba skonwertować do stringa)
$data = ['name' => 'Jan', 'age' => 25];
error_log("User data: " . print_r($data, true));

// Loguj z kontekstem
error_log("[" . date('Y-m-d H:i:s') . "] User logged in: $user_id");
?>

Logowanie do konkretnego pliku

<?php
// Loguj do konkretnego pliku
error_log("Custom log message", 3, "/var/log/myapp/custom.log");

// Funkcja pomocnicza do logowania
function logMessage($message, $level = 'INFO') {
    $timestamp = date('Y-m-d H:i:s');
    $log_file = __DIR__ . '/logs/app.log';
    
    $formatted_message = "[$timestamp] [$level] $message" . PHP_EOL;
    
    error_log($formatted_message, 3, $log_file);
}

// Użycie
logMessage("Aplikacja uruchomiona", "INFO");
logMessage("Użytkownik zalogowany: ID 123", "INFO");
logMessage("Błąd połączenia z bazą", "ERROR");
logMessage("Podejrzana aktywność z IP: 192.168.1.100", "WARNING");
?>

Przykład – Klasa Logger

<?php
class Logger {
    
    private $log_file;
    private $levels = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'];
    
    public function __construct($log_file = null) {
        $this->log_file = $log_file ?? __DIR__ . '/logs/app.log';
        
        // Utwórz katalog jeśli nie istnieje
        $log_dir = dirname($this->log_file);
        if (!is_dir($log_dir)) {
            mkdir($log_dir, 0755, true);
        }
    }
    
    private function write($level, $message, $context = []) {
        $timestamp = date('Y-m-d H:i:s');
        
        // Format: [2024-01-15 14:30:45] [ERROR] Message
        $log_line = "[$timestamp] [$level] $message";
        
        // Dodaj kontekst jeśli istnieje
        if (!empty($context)) {
            $log_line .= " | Context: " . json_encode($context);
        }
        
        $log_line .= PHP_EOL;
        
        // Zapisz do pliku
        error_log($log_line, 3, $this->log_file);
    }
    
    public function debug($message, $context = []) {
        $this->write('DEBUG', $message, $context);
    }
    
    public function info($message, $context = []) {
        $this->write('INFO', $message, $context);
    }
    
    public function warning($message, $context = []) {
        $this->write('WARNING', $message, $context);
    }
    
    public function error($message, $context = []) {
        $this->write('ERROR', $message, $context);
    }
    
    public function critical($message, $context = []) {
        $this->write('CRITICAL', $message, $context);
    }
    
    public function exception(Exception $e) {
        $context = [
            'file' => $e->getFile(),
            'line' => $e->getLine(),
            'trace' => $e->getTraceAsString()
        ];
        
        $this->error($e->getMessage(), $context);
    }
}

// Użycie
$logger = new Logger();

$logger->info("Aplikacja uruchomiona");
$logger->debug("Zmienna \$x = 5", ['x' => 5]);
$logger->warning("Niska pamięć", ['available' => '50MB']);
$logger->error("Błąd połączenia z bazą", ['host' => 'localhost', 'error' => 'timeout']);

try {
    throw new Exception("Testowy wyjątek");
} catch (Exception $e) {
    $logger->exception($e);
}
?>

5. var_dump(), print_r(), debug_backtrace()

var_dump() – Pełne informacje o zmiennej

<?php
$string = "Hello World";
$number = 42;
$float = 3.14;
$bool = true;
$null = null;
$array = ['a' => 1, 'b' => 2, 'c' => 3];
$object = new stdClass();
$object->name = "Test";
$object->value = 100;

// var_dump() pokazuje typ i wartość
var_dump($string);      // string(11) "Hello World"
var_dump($number);      // int(42)
var_dump($float);       // float(3.14)
var_dump($bool);        // bool(true)
var_dump($null);        // NULL
var_dump($array);       // array(3) { ["a"]=> int(1) ...}
var_dump($object);      // object(stdClass)#1 (2) { ...}

// Wiele zmiennych naraz
var_dump($string, $number, $array);
?>

print_r() – Czytelniejszy output

<?php
$array = [
    'user' => [
        'id' => 123,
        'name' => 'Jan Kowalski',
        'email' => 'jan@example.com',
        'roles' => ['admin', 'editor']
    ],
    'settings' => [
        'theme' => 'dark',
        'language' => 'pl'
    ]
];

// print_r() - czytelniejsze formatowanie
print_r($array);

// Zwróć jako string zamiast wyświetlać
$output = print_r($array, true);
error_log("Array content: $output");

// Porównanie
echo "<pre>";
echo "var_dump():\n";
var_dump($array);

echo "\nprint_r():\n";
print_r($array);
echo "</pre>";
?>

debug_backtrace() – Śledzenie wywołań

<?php
function levelOne() {
    levelTwo();
}

function levelTwo() {
    levelThree();
}

function levelThree() {
    // Pokaż stos wywołań
    $backtrace = debug_backtrace();
    
    echo "<pre>";
    print_r($backtrace);
    echo "</pre>";
    
    // Bardziej czytelne
    echo "<h3>Call Stack:</h3>";
    foreach ($backtrace as $index => $call) {
        echo "#{$index} ";
        
        if (isset($call['file'])) {
            echo "{$call['file']}:{$call['line']} - ";
        }
        
        if (isset($call['class'])) {
            echo "{$call['class']}{$call['type']}";
        }
        
        echo "{$call['function']}()<br>";
    }
}

levelOne();
?>

Funkcja debug() – Ulepszony var_dump()

<?php
function debug($var, $label = null, $die = false) {
    echo '<pre style="background:#f4f4f4; padding:15px; border:2px solid #333; border-radius:5px; margin:10px 0;">';
    
    if ($label) {
        echo "<strong>DEBUG: $label</strong>\n";
        echo str_repeat('-', 50) . "\n";
    }
    
    // Informacje o wywołaniu
    $backtrace = debug_backtrace();
    $caller = $backtrace[0];
    echo "File: {$caller['file']}\n";
    echo "Line: {$caller['line']}\n";
    echo str_repeat('-', 50) . "\n\n";
    
    // Typ zmiennej
    echo "Type: " . gettype($var) . "\n\n";
    
    // Wartość
    if (is_array($var) || is_object($var)) {
        print_r($var);
    } else {
        var_dump($var);
    }
    
    echo '</pre>';
    
    if ($die) {
        die("\n<strong>Script terminated by debug()</strong>");
    }
}

// Użycie
$user = [
    'id' => 123,
    'name' => 'Jan Kowalski',
    'active' => true
];

debug($user, "User Data");
debug($_SERVER, "Server Variables");
debug($user, "Critical Error - User Data", true); // Zatrzyma skrypt
?>

6. Try-Catch – Obsługa wyjątków

Materiał dostępny w temacie https://kamakaczmarek.net/12-php-wyjatki-try-catch/

7. Xdebug – Profesjonalne debugowanie

Xdebug to popularne rozszerzenie (moduł) dla języka PHP, które służy do debugowania (szukania błędów), profilowania oraz analizy wydajności aplikacji.

Zamiast polegać na tradycyjnym, często uciążliwym wypisywaniu wartości zmiennych w oknie przeglądarki za pomocą funkcji var_dump() czy print_r(),

Xdebug pozwala na zaawansowaną, interaktywną pracę z kodem.

W tym miejscu odsyłam Was do zewnętrznych źródeł celem zgłębienia tematu – jest ich mnóstwo w sieci.

TaskQuest 2.3 — tryb pracy, log błędów i strona awarii

Po Lekcji 22 TaskQuest ma prawdziwe konta, hasła i obronę przed trzema atakami. Ale kiedy coś w środku pęknie, aplikacja zachowuje się fatalnie: pokazuje graczowi nazwy kolumn z bazy, pełne ścieżki do plików na serwerze, a czasem po prostu białą stronę. I nie zostawia po sobie żadnego śladu — gdy uczeń z klasy obok napisze „nie działa”, nie masz jak sprawdzić, co się stało.

Dziś zajmiesz się tym, co dzieje się po wystąpieniu błędu: kto zobaczy szczegóły, co zostanie zapisane i jak znaleźć przyczynę. Przy okazji poznasz narzędzia, których będziesz używać do końca nauki programowania.

Pracujesz na projekcie w stanie po Lekcji 22.

Cel lekcji

Nauczyć się:

  • rozpoznawać rodzaje błędów PHP (Parse error, Fatal error, Warning, Deprecated) i czytać ich komunikaty,
  • rozróżniać Exception i Error oraz wiedzieć, czego nie łapie catch (Exception ...),
  • sterować wyświetlaniem błędów (error_reporting(), ini_set('display_errors', ...)),
  • zapisywać błędy do pliku (error_log()) i używać logu jako narzędzia debugowania,
  • ustawiać awaryjną obsługę niezłapanych wyjątków (set_exception_handler()),
  • świadomie używać var_dump(), print_r() i wiedzieć, kiedy one nie wystarczą.

Efekt: TaskQuest ma dwa tryby pracy (rozwojowy i produkcyjny), własny plik logu, do którego trafia każda awaria, i stronę awarii zamiast białego ekranu ze ścieżkami serwera.

Uwaga — co jest, a czego jeszcze nie ma:

  • Nie zmieniamy sposobu walidacji danych od użytkownika. Błąd użytkownika to nadal tablica komunikatów (reguła z L13), a nie wyjątek.
  • Nie wprowadzamy własnych klas wyjątków (to wciąż zadanie dodatkowe z L15) ani trigger_error() — w TaskQuest awarie zgłaszamy wyjątkami (L12).
  • Xdebug omawiamy tylko krótko na końcu. To narzędzie do zainstalowania, nie kod w projekcie.

Rozgrzewka — 4 pytania z Lekcji 22

  1. Dlaczego htmlspecialchars() wywołujemy przy wyświetlaniu, a nie przed zapisem do bazy?
  2. Skąd sprawdzTokenCsrf() wie, że formularz pochodzi z TaskQuest, a nie z obcej strony?
  3. Dlaczego przy nieudanym logowaniu pokazujemy jeden wspólny komunikat „Nieprawidłowy login lub hasło.”?
  4. Co robi .htaccess w uploads/avatary/ i przed czym nie chroni?

1. Wracamy do naszego TaskQuest

Co zostaje, co dochodzi, co zmieniamy

ElementPo Lekcji 22Po Lekcji 23
logowanie, rejestracja, zadania, XP, avatary, rankingdziałajądziałają tak samo
walidacja formularzytablica $bledybez zmian
komunikat awarii u graczaoryginalny tekst mysqli (np. nazwa kolumny)zmiana: w trybie rozwojowym szczegóły, w produkcyjnym jedno ogólne zdanie
ślad po awariiżadendochodzi: wpis w logs/taskquest.log
config/config.phpsame stałedochodzą: TRYB_ROZWOJOWY, PLIK_LOGU, ustawienia błędów, włączenie obsługi wyjątków
includes/funkcje_bledow.phpnie manowy plik: zapiszBladDoLogu(), komunikatAwarii(), pokazAwarie()
logs/nie manowy katalog + .htaccess
7 bloków catch$blad = $wyjatek->getMessage();zmiana: zapis do logu + komunikat zależny od trybu
niezłapany Error/TypeErrorbiała strona albo Fatal error z pełną ścieżkązmiana: strona awarii + wpis w logu
diagnostyka.php23 ścieżkizmiana: 25 ścieżek + sekcja „Log błędów”

Docelowa struktura projektu

taskquest/
├── config/config.php                (zmiana: tryb pracy, ustawienia błędów, set_exception_handler)
├── includes/
│   ├── funkcje_bledow.php           NOWY
│   ├── footer.php                   (zmiana: „TaskQuest 2.3”)
│   └── ... pozostałe                bez zmian
├── logs/                            NOWY KATALOG
│   ├── .htaccess                    NOWY
│   └── taskquest.log                (tworzy go PHP przy pierwszym błędzie)
├── index.php                        (zmiana: blok catch)
├── zaloguj.php                      (zmiana: blok catch)
├── rejestracja.php                  (zmiana: blok catch)
├── dodaj-zadanie.php                (zmiana: blok catch)
├── profil.php                       (zmiana: blok catch)
├── oznacz-wykonane.php              (zmiana: blok catch)
├── usun-zadanie.php                 (zmiana: blok catch)
├── diagnostyka.php                  (zmiana: 25 ścieżek, sekcja „Log błędów”)
└── classes/, sql/, uploads/         bez zmian

Przygotowanie (zrób to teraz)

  1. Zrób kopię zapasową katalogu taskquest/ jako taskquest_lekcja22/ (obok, nie w środku).
  2. Uruchom w XAMPP Apache i MySQL.
  3. Zaloguj się jako Kama_2026 / KamaQuest26 i sprawdź, że lista zadań, „Zrobione!”, „Usuń” i avatar działają.
  4. Znajdź na dysku plik C:\xampp\apache\logs\error.log (na Linuksie /opt/lampp/logs/error_log). Otwórz go w edytorze kodu i przewiń na koniec. To tu dziś trafiają wszystkie komunikaty PHP — za chwilę damy TaskQuest własny plik.

2. Problem w aplikacji

Trzy rzeczy, które dzieją się w TaskQuest już dziś.

1. Aplikacja zdradza wnętrze bazy. Otwórz includes/funkcje_bazy.php, znajdź wczytajZadaniaUzytkownika() i w tekście zapytania zmień tytul na tytul2. Odśwież index.php:

Nie udało się wczytać danych: Unknown column 'tytul2' in 'field list'

Gracz właśnie dostał informację, jak nazywają się kolumny w Twojej bazie. Po Lekcji 22 wiesz, że to prezent dla atakującego. Przywróć tytul.

2. Aplikacja zdradza wnętrze serwera. Otwórz dodaj-zadanie.php, znajdź w formularzu pole tytułu i zmień name="tytul" na name="tytul[]". Wyślij formularz z dowolnym tytułem:

Fatal error: Uncaught TypeError: trim(): Argument #1 ($string) must be of type string,
array given in C:\xampp\htdocs\taskquest\includes\funkcje_tekstowe.php:14

Zwróć uwagę na dwie rzeczy. Po pierwsze — pełna ścieżka do pliku na dysku serwera. Po drugie — blok try/catch w tym pliku tego nie złapał. Dlaczego, dowiesz się w sekcji 3.3. Przywróć name="tytul".

3. Nie ma żadnego śladu. Oba błędy zniknęły razem z odświeżeniem strony. Gdyby zdarzyły się graczowi w nocy, rano nie miałabyś jak sprawdzić, co się stało. Jedyny ślad jest w logu Apache — wspólnym dla wszystkich aplikacji na tym serwerze.

Plan lekcji:

rodzaje błędów PHP        → umiesz przeczytać komunikat i wiesz, co się stało
Exception vs Error        → wiesz, czego catch (Exception) nie łapie
tryb pracy aplikacji      → decydujesz, kto widzi szczegóły
log błędów                → każda awaria zostawia ślad
set_exception_handler()   → żaden błąd nie pokaże graczowi ścieżek
var_dump / print_r / log  → potrafisz znaleźć przyczynę

3. Rodzaje błędów w PHP

3.1 Cztery rodzaje komunikatów

Utwórz w katalogu projektu plik brudnopis.php. Będziesz do niego wracać przez całą lekcję.

Parse error (błąd składni). PHP nie potrafi nawet przeczytać pliku, więc nic się nie wykonuje:

<?php

echo "Start";
echo "Brakuje średnika"
echo "Koniec";

Uruchom http://localhost/taskquest/brudnopis.php. Słowo „Start” nie pojawi się, mimo że jest przed błędem. To jest cecha rozpoznawcza błędu składni: cały plik przepada.

Fatal error (błąd krytyczny). Skrypt rusza, ale przerywa się w miejscu błędu:

<?php

echo "<p>Start</p>";
nieistniejacaFunkcja();
echo "<p>Koniec</p>";

Tym razem „Start” się wyświetli, a „Koniec” już nie.

Warning (ostrzeżenie). Skrypt nie przerywa pracy:

<?php

$plik = fopen('nie_ma_takiego_pliku.txt', 'r');
echo "<p>Mimo ostrzeżenia lecę dalej.</p>";
echo "<p>Zmienna: " . $nieistniejacaZmienna . "</p>";

To najbardziej podstępny rodzaj błędu. Strona działa, ale wynik jest nie ten, którego oczekujesz. Znasz to z Lekcji 21: move_uploaded_file() przy braku katalogu też wypisuje Warning — ze ścieżką do katalogu na serwerze.

Deprecated (przestarzałe). Informacja, że coś działa, ale zniknie w przyszłej wersji PHP. Spotkałaś to w Lekcji 11 przy fgetcsv() bez ostatniego parametru.

Poprawka do materiału źródłowego. Materiał wymienia jeszcze Notice i podaje przykłady: niezdefiniowana zmienna, brakujący klucz tablicy, dzielenie przez zero. W PHP 8 te trzy przypadki zachowują się inaczej niż w PHP 5/7:

  • niezdefiniowana zmienna → Warning (nie Notice),
  • brakujący klucz tablicy → Warning (nie Notice),
  • 10 / 0 → rzuca DivisionByZeroError, czyli przerywa skrypt (nie Warning).

Notice nadal istnieje, ale jest dziś rzadkością. Sprawdź to sama — wpisz w brudnopisie echo 10 / 0; i zobacz, co pokaże PHP.

3.2 Jak czytać komunikat błędu

Każdy komunikat ma cztery części:

Fatal error: Uncaught TypeError: trim(): Argument #1 ($string) must be of type string, array given
             in C:\xampp\htdocs\taskquest\includes\funkcje_tekstowe.php:14
[1] Fatal error              → rodzaj: skrypt się przerwał
[2] Uncaught TypeError       → nazwa klasy błędu
[3] trim(): Argument #1 ...  → co dokładnie jest nie tak
[4] funkcje_tekstowe.php:14  → plik i numer linii

Reguła praktyczna: czytaj od końca. Najpierw plik i linia, potem treść. Uwaga na pułapkę — linia 14 to miejsce, w którym błąd wybuchł, a nie zawsze miejsce, w którym powstał. trim() dostało tablicę, ale to nie trim() jest winne — winne jest miejsce, które przekazało tablicę. Dlatego pod komunikatem PHP pokazuje stos wywołań (Stack trace): listę funkcji, które doprowadziły do tego miejsca, od najbardziej zagnieżdżonej do najwyższej.

3.3 Error czy Exception?

Od Lekcji 12 używasz try/catch. W sekcji 2 zobaczyłaś, że TypeError przeszedł obok bloku catch. To nie jest przypadek ani błąd w Twoim kodzie.

W PHP wszystko, co można rzucić, dziedziczy po interfejsie Throwable. Poniżej są dwie rodziny:

              Throwable
/ \
Exception Error
| |
mysqli_sql_exception TypeError
DateMalformedString… ValueError
(Twoje wyjątki z L12) DivisionByZeroError
ArgumentCountError

Podział ma sens praktyczny:

  • Exception to sytuacja, którą program przewidział: baza nie odpowiada, pliku nie ma, dane są niepoprawne. Program może zareagować.
  • Error to błąd programisty: zła liczba argumentów, zły typ, wywołanie nieistniejącej funkcji. Nie ma sensownej reakcji poza „napraw kod”.

catch (Exception $wyjatek) łapie wyłącznie lewą gałąź. To jest zamierzone: gdyby łapało wszystko, Twoje literówki udawałyby awarie bazy danych.

SAMODZIELNIE

Wyczyść brudnopis.php i wstaw:

<?php

function sprawdz(callable $kod): void
{
    try {
        $kod();
        echo "<p>Bez błędu.</p>";
    } catch (Exception $wyjatek) {
        echo "<p>Złapane jako Exception: " . get_class($wyjatek) . "</p>";
    }
}

Nie musisz rozumieć callable — to po prostu sposób na przekazanie kawałka kodu do funkcji. Pod spodem dopisz pięć wywołań sprawdz(...), po jednym dla każdej sytuacji:

1. new DateTime('2026-13-45')
2. 10 / 0
3. trim([])
4. throw new Exception('Awaria pliku')
5. intdiv(10, 3)

Wzór pierwszego: sprawdz(function () { new DateTime('2026-13-45'); });

Zanim uruchomisz, wpisz w zeszycie przewidywanie dla każdego przypadku: „złapane” czy „wywali stronę”. Potem uruchom i sprawdź.

HINT

Trzy z pięciu przypadków to Error. Zwróć uwagę, który przypadek przerwie stronę tak, że kolejne wywołania sprawdz() w ogóle się nie wykonają.

POMOC

Żeby zobaczyć wszystkie pięć wyników, zacznij od przypadków, które są łapane. Nazwę klasy każdego błędu odczytasz z komunikatu PHP (Uncaught ...).

ROZWIĄZANIE
1. new DateTime('2026-13-45')   → ZŁAPANE — DateMalformedStringException (dziedziczy po Exception)
2. 10 / 0                       → NIE — DivisionByZeroError (Error), strona się przerywa
3. trim([])                     → NIE — TypeError (Error), strona się przerywa
4. throw new Exception(...)     → ZŁAPANE — Exception
5. intdiv(10, 3)                → bez błędu, wynik 3

Przypadek 1 to dokładnie sytuacja z pozycji „znane uproszczenia” Twojego projektu: termin o poprawnym kształcie, ale nieistniejący w kalendarzu, wywraca komunikatTerminu() i czyPoTerminie().

Wniosek dla TaskQuest: nie będziemy zamieniać catch (Exception ...) na catch (Throwable ...). Zostawiamy dotychczasowy podział — przewidziane awarie łapiemy lokalnie, a dla błędów programisty zbudujemy w sekcji 6 jedną wspólną siatkę bezpieczeństwa.

4. Tryb rozwojowy i produkcyjny

4.1 error_reporting() i ini_set()

Dwa ustawienia decydują o tym, co się dzieje z komunikatem błędu:

UstawienieZnaczenie
error_reporting(E_ALL)które błędy PHP w ogóle bierze pod uwagę
ini_set('display_errors', '1')czy komunikat trafia na stronę
ini_set('log_errors', '1')czy komunikat trafia do pliku
ini_set('error_log', ...)do którego pliku

Te cztery ustawienia dają dwa sensowne zestawy:

TRYB ROZWOJOWY (Twój komputer)      TRYB PRODUKCYJNY (serwer, gracze)
error_reporting(E_ALL)              error_reporting(E_ALL)
display_errors = 1   ← widzisz      display_errors = 0   ← gracz nie widzi
log_errors = 1                      log_errors = 1       ← Ty czytasz z pliku

Zwróć uwagę: error_reporting(E_ALL) jest w obu trybach. Wyłączanie ostrzeżeń na produkcji to najczęstszy błąd początkujących — problem nie znika, tylko przestaje być widoczny. Różnica między trybami dotyczy wyłącznie tego, kto dostaje komunikat.

ini_set() zmienia ustawienie PHP na czas jednego żądania. Trwale ustawia się je w php.ini, ale wtedy dotyczą wszystkich aplikacji na serwerze — a Ty chcesz sterować samym TaskQuest.

Ważne ograniczenie. ini_set('display_errors', '0') działa dopiero od momentu wykonania tej linii. Błąd składni w pliku, który PHP wczytuje jako pierwszy (czyli w index.php), zdarza się wcześniej — takiego komunikatu ustawienie nie ukryje. To jedyny przypadek, którego nie da się objąć kodem; na serwerze załatwia go php.ini. Zobaczysz to w Eksperymencie 2.

4.2 Włączamy tryby do TaskQuest

Od tego miejsca pracujesz w projekcie. Brudnopis zostaw, wróci w sekcji 7.

Krok 1 — katalog na log

  1. Utwórz w katalogu projektu katalog logs/ (ręcznie, tak jak uploads/avatary/ w Lekcji 21 — aplikacja sama katalogów nie tworzy).
  2. Utwórz w nim plik .htaccess o treści:
# TaskQuest (L23): log błędów nie jest do oglądania przez przeglądarkę.
Require all denied

Dlaczego? Log będzie zawierał nazwy kolumn, ścieżki na dysku i fragmenty zapytań SQL — czyli dokładnie to, co w Lekcji 22 uznałyśmy za informacje wyłącznie dla programisty. Katalog logs/ leży w katalogu publicznym serwera, więc bez .htaccess każdy mógłby wpisać adres pliku i przeczytać go w przeglądarce.

Krok 2 — ustawienia w config/config.php

Na samej górze pliku, zaraz pod <?php i przed dotychczasowymi stałymi, dopisz:

// --- tryb pracy aplikacji (L23) ---
// true  = Twój komputer: błędy widać na stronie
// false = serwer: gracz widzi tylko ogólny komunikat, szczegóły idą do logu
define('TRYB_ROZWOJOWY', true);

define('PLIK_LOGU', dirname(__DIR__) . '/logs/taskquest.log');

error_reporting(E_ALL);
ini_set('log_errors', '1');
ini_set('error_log', PLIK_LOGU);

if (TRYB_ROZWOJOWY) {
    ini_set('display_errors', '1');
} else {
    ini_set('display_errors', '0');
}

dirname(__DIR__) znasz — __DIR__ to katalog config/, a dirname() cofa o jeden poziom, do katalogu projektu. Tak samo liczona była kiedyś stała PLIK_ZADAN.

Krok 3 — sprawdzenie

  1. Zepsuj celowo nazwę kolumny w wczytajZadaniaUzytkownika() (w zapytaniu sql zmień tytul na tytul2) i odśwież index.php.
  2. Zajrzyj do logs/taskquest.log. Powinien tam być wpis z datą i komunikatem mysqli.
  3. Wpisz w przeglądarce http://localhost/taskquest/logs/taskquest.log. Serwer powinien odpowiedzieć 403 Forbidden.
  4. Przywróć tytul.

Jeśli pliku logu nie ma, sprawdź pisownię nazwy katalogu logs. PHP, jeśli nie może pisać do wskazanego pliku, nie zgłasza tego — po prostu milczy (Eksperyment 3).

5. Log błędów

5.1 error_log()

Ustawienia z sekcji 4 zajmują się błędami, które zgłasza samo PHP. Ale wyjątek złapany przez catch nie jest dla PHP błędem — program go obsłużył, więc nic nigdzie nie trafia. To właśnie nasz przypadek: siedem bloków catch w TaskQuest wyświetla komunikat graczowi i zapomina o sprawie.

Do dopisania własnego wpisu służy error_log():

error_log('Nie udało się połączyć z bazą danych.');

Wpis trafia tam, gdzie wskazuje ustawienie error_log, czyli teraz do logs/taskquest.log. Datę i godzinę PHP dopisuje samo (strefa zależy od ustawienia date.timezone), więc nie wołamy date().

Sprawdź to — dopisz tę linię tymczasowo na końcu config/config.php, odśwież dowolną stronę, zajrzyj do logu i usuń linię.

5.2 SAMODZIELNIE — dwie funkcje

Utwórz plik includes/funkcje_bledow.php i napisz w nim dwie funkcje.

zapiszBladDoLogu(Throwable $wyjatek, string $gdzie): void — zapisuje do logu jedną linię zawierającą: etykietę $gdzie, nazwę klasy wyjątku, jego komunikat oraz plik i numer linii.

komunikatAwarii(Throwable $wyjatek): string — zwraca tekst dla gracza: w trybie rozwojowym komunikat wyjątku, w produkcyjnym jedno ogólne zdanie, np. Coś się popsuło po naszej stronie. Spróbuj jeszcze raz za chwilę.

Wymagania:

  • typ parametru to Throwable, a nie Exception — te funkcje mają obsłużyć obie gałęzie z sekcji 3.3,
  • żadna z nich niczego nie wyświetla,
  • do logu nie trafia stos wywołań.
HINT

Nazwę klasy obiektu zwraca get_class($obiekt). Plik i linię masz w metodach getFile() i getLine() — poznałaś je w Lekcji 12 jako „informacje wyłącznie dla programisty”. Sklejanie tekstu znasz od Lekcji 1.

POMOC
function zapiszBladDoLogu(Throwable $wyjatek, string $gdzie): void
{
    $opis = get_class($wyjatek) . ': ' . $wyjatek->getMessage()
        . ' (' . $wyjatek->getFile() . ':' . $wyjatek->getLine() . ')';

    error_log(/* ... etykieta i opis ... */);
}

W komunikatAwarii() wystarczy if (TRYB_ROZWOJOWY) i dwa return.

ROZWIĄZANIE
<?php

// Funkcje obsługi błędów TaskQuest (Lekcja 23).
// Plik wymaga config/config.php (stałe TRYB_ROZWOJOWY i PLIK_LOGU).

function zapiszBladDoLogu(Throwable $wyjatek, string $gdzie): void
{
    $opis = get_class($wyjatek) . ': ' . $wyjatek->getMessage()
        . ' (' . $wyjatek->getFile() . ':' . $wyjatek->getLine() . ')';

    error_log('[' . $gdzie . '] ' . $opis);
}

function komunikatAwarii(Throwable $wyjatek): string
{
    if (TRYB_ROZWOJOWY) {
        return $wyjatek->getMessage();
    }

    return 'Coś się popsuło po naszej stronie. Spróbuj jeszcze raz za chwilę.';
}

Dlaczego dwie funkcje, a nie jedna? Bo mają dwa różne zadania i dwóch różnych odbiorców: log jest dla Ciebie, komunikat dla gracza. Zapis do logu ma się wykonać zawsze, niezależnie od trybu; treść komunikatu zależy od trybu. Gdyby to była jedna funkcja, nie dałoby się zalogować błędu bez pokazywania go.

Dlaczego bez stosu wywołań? getTraceAsString() zawiera wartości argumentów funkcji. Wśród nich byłoby hasło przekazane do password_verify(). W Lekcji 22 ustaliłyśmy, że hasło nie trafia do value, sesji, komunikatów ani logów — ta reguła obowiązuje dalej.

5.3 Włączamy funkcje do TaskQuest

Najpierw dołącz nowy plik. Na końcu config/config.php, pod blokiem z kroku 4.2, dopisz:

require_once dirname(__DIR__) . '/includes/funkcje_bledow.php';

To wyjątek od naszej konwencji. Do tej pory pliki funkcji dołączały strony, a nie inne pliki. Tutaj robimy inaczej, bo obsługa błędów musi działać od pierwszej linii każdej strony, a config/config.php jest jedynym plikiem dołączanym na starcie wszystkich stron. Alternatywą byłoby dopisanie tej samej linii do dziewięciu plików — czyli dokładnie to, co w zadaniu B z Lekcji 22 uznałyśmy za rozwiązanie do poprawienia.

Teraz zmień siedem bloków catch. Wzorzec jest zawsze ten sam — było:

} catch (Exception $wyjatek) {
    $bladBazy = $wyjatek->getMessage();
}

ma być:

} catch (Exception $wyjatek) {
    zapiszBladDoLogu($wyjatek, 'index.php — dane gracza i zadania');
    $bladBazy = komunikatAwarii($wyjatek);
}

Miejsca i etykiety (Ctrl+Shift+F → catch (Exception):

PlikZmiennaEtykieta $gdzie
index.php$bladBazyindex.php — dane gracza i zadania
zaloguj.php$bladBazyzaloguj.php — logowanie
rejestracja.php$bladBazyrejestracja.php — zakładanie konta
dodaj-zadanie.php$bladZapisudodaj-zadanie.php — zapis zadania
profil.php$bladZapisuprofil.php — zapis avatara
oznacz-wykonane.php(ramka awarii)oznacz-wykonane.php
usun-zadanie.php(ramka awarii)usun-zadanie.php

W oznacz-wykonane.php i usun-zadanie.php nie ma zmiennej z komunikatem — wyjątek jest wypisywany wprost w ramce awarii. Tam blok wygląda tak:

} catch (Exception $wyjatek) {
    zapiszBladDoLogu($wyjatek, 'oznacz-wykonane.php');
    $bladAkcji = komunikatAwarii($wyjatek);

    $nazwaStrony = 'TaskQuest';
    require_once __DIR__ . '/includes/header.php';
    echo '<p class="blad">Nie udało się oznaczyć zadania: ' . htmlspecialchars($bladAkcji) . '</p>';
    // ... link powrotny, footer, exit (bez zmian)
}

htmlspecialchars() zostaje, mimo że w trybie produkcyjnym komunikat jest wpisany w kodzie. W trybie rozwojowym nadal może zawierać tekst z bazy.

Sprawdzenie: zepsuj nazwę kolumny, odśwież index.php — ramka wygląda jak wcześniej, ale w logu jest wpis. Potem zmień TRYB_ROZWOJOWY na false i odśwież ponownie: gracz widzi ogólne zdanie, a log dalej ma szczegóły. Przywróć tytul i true.

6. Strona awarii — set_exception_handler()

6.1 Problem

Sekcja 5 zabezpieczyła wyjątki, które łapiemy. Zostaje druga gałąź z sekcji 3.3: TypeError, DivisionByZeroError, wywołanie nieistniejącej funkcji. Te nadal kończą się komunikatem Fatal error ze ścieżką — a w trybie produkcyjnym, gdzie display_errors jest wyłączone, białą stroną bez żadnej informacji.

set_exception_handler() ustawia funkcję, którą PHP wywoła dla każdego wyjątku, którego nikt nie złapał:

set_exception_handler('nazwaFunkcji');

Funkcja dostaje jeden argument — obiekt Throwable. Po jej zakończeniu skrypt i tak się kończy, ale to my decydujemy, co zobaczy gracz.

Handler obejmuje wszystko, co jest wyjątkiem lub błędem klasy Error — czyli także „Call to undefined function”. Nie obejmuje: błędów składni (plik w ogóle się nie wykonuje), przekroczenia limitu pamięci i czasu oraz zwykłych Warning (te nie są wyjątkami — zajmują się nimi ustawienia z sekcji 4).

6.2 SAMODZIELNIE — pokazAwarie()

Dopisz w includes/funkcje_bledow.php funkcję pokazAwarie(Throwable $wyjatek): void, która:

  1. zapisze błąd do logu z etykietą niezłapany wyjątek,
  2. ustawi kod odpowiedzi HTTP na 500, jeśli nagłówki nie zostały jeszcze wysłane,
  3. wyświetli prostą stronę: nagłówek „TaskQuest”, zdanie dla gracza i link do index.php,
  4. tylko w trybie rozwojowym dopisze klasę wyjątku, komunikat, plik z numerem linii i stos wywołań.

Dwie wskazówki co do formy: nie używaj header.php ani footer.php (awaria może dotyczyć właśnie ich — strona błędu nie może zależeć od tego, co się zepsuło) i pamiętaj o htmlspecialchars() przy każdej wartości spoza kodu.

HINT

Do punktu 2 potrzebujesz dwóch funkcji: headers_sent() zwraca true, jeśli PHP wysłało już cokolwiek do przeglądarki, a http_response_code(500) ustawia kod odpowiedzi.

POMOC
function pokazAwarie(Throwable $wyjatek): void
{
    zapiszBladDoLogu($wyjatek, 'niezłapany wyjątek');

    if (!headers_sent()) {
        http_response_code(500);
    }

    echo '<h1>TaskQuest</h1>';
    // ... zdanie dla gracza, szczegóły tylko gdy TRYB_ROZWOJOWY, link powrotny
}
ROZWIĄZANIE
function pokazAwarie(Throwable $wyjatek): void
{
    zapiszBladDoLogu($wyjatek, 'niezłapany wyjątek');

    if (!headers_sent()) {
        http_response_code(500);
    }

    echo '<h1>TaskQuest</h1>';
    echo '<p>Coś się popsuło po naszej stronie. Spróbuj jeszcze raz za chwilę.</p>';

    if (TRYB_ROZWOJOWY) {
        echo '<p><strong>' . htmlspecialchars(get_class($wyjatek)) . ':</strong> '
            . htmlspecialchars($wyjatek->getMessage()) . '</p>';
        echo '<p>' . htmlspecialchars($wyjatek->getFile()) . ':' . $wyjatek->getLine() . '</p>';
        echo '<pre>' . htmlspecialchars($wyjatek->getTraceAsString()) . '</pre>';
    }

    echo '<p><a href="index.php">← Wróć do TaskQuest</a></p>';
}

Po co headers_sent()? Kod odpowiedzi HTTP jest częścią nagłówków, a te lecą do przeglądarki przed treścią strony. Jeśli awaria zdarzy się w połowie listy zadań, nagłówki są już dawno wysłane i http_response_code() zgłosiłby ostrzeżenie „Cannot modify header information — headers already sent”. To zresztą jeden z najczęstszych komunikatów, jakie zobaczysz w PHP — zawsze znaczy to samo: coś już zostało wypisane, a Ty próbujesz ustawić nagłówek (zobacz Eksperyment 5).

Po co kod 500? Przeglądarka i wyszukiwarki poznają po nim, że strona nie została poprawnie wygenerowana. Bez tego awaria wygląda jak zwyczajna strona z tekstem.

6.3 Włączamy handler

W config/config.php, w ostatniej linii pliku (pod require_once z sekcji 5.3), dopisz:

set_exception_handler('pokazAwarie');

Kolejność jest ważna: najpierw plik z funkcjami, dopiero potem rejestracja handlera — inaczej PHP nie zna jeszcze funkcji pokazAwarie.

Sprawdzenie: powtórz atak z sekcji 2 — zmień w dodaj-zadanie.php pole na name="tytul[]" i wyślij formularz. Zamiast Fatal error ze ścieżką dostaniesz stronę awarii, a w logu pojawi się wpis [niezłapany wyjątek] TypeError: .... Przełącz TRYB_ROZWOJOWY na false i powtórz: gracz widzi samo zdanie, Ty masz wszystko w logu. Przywróć name="tytul" i true.

Zauważ, że problem z polem wysłanym jako tablica nie zniknął — zmieniło się tylko to, co widzi gracz. Prawdziwa poprawka (sprawdzanie is_string() przy odczycie pól) to zadanie końcowe B.

7. Kolejne zadanie — znajdź cudzy błąd

Narzędzia, których używasz do zaglądania w środek działającego programu:

var_dump($zmienna);    // typ + wartość, najdokładniejsze
print_r($tablica);     // czytelniejsze dla tablic i obiektów, bez typów
print_r($tablica, true);  // zwraca tekst zamiast wypisywać — nadaje się do error_log()

Wypróbuj w brudnopisie różnicę na ['a' => 1, 'b' => '1', 'c' => true]. print_r() pokaże trzy podobne wartości, var_dump() pokaże, że to int, string i bool. Przy szukaniu błędu ta różnica jest zwykle kluczowa. Obie funkcje warto otaczać <pre>, żeby zachować formatowanie.

Ale one nie zawsze działają. Strony akcji w TaskQuest (oznacz-wykonane.php, usun-zadanie.php, wyloguj.php) kończą się przekierowaniem. Przeglądarka natychmiast idzie pod nowy adres i nie pokazuje niczego, co ten skrypt wypisał. Dokładnie w tych plikach potrzebujesz logu.

SAMODZIELNIE

Wprowadź do projektu cudzy błąd: w oznacz-wykonane.php znajdź wywołanie zapiszPostepUzytkownika(...) i zamień miejscami dwa ostatnie argumenty (XP i streak).

Zgłoszenie od gracza brzmi: „Klikam Zrobione!, zadanie znika z listy aktywnych, ale XP prawie się nie zmienia, za to seria skacze o kilkadziesiąt”.

Twoje zadanie: nie patrząc na to, co zmieniłaś, znajdź przyczynę, używając logu. Dopisz do oznacz-wykonane.php tymczasowe wpisy error_log() pokazujące wartości przekazywane do funkcji, kliknij „Zrobione!”, odczytaj log i wyciągnij wniosek.

Oczekiwany efekt: w logu widzisz dwie liczby w kolejności, która nie zgadza się z sygnaturą funkcji.

HINT

zapiszPostepUzytkownika(mysqli $polaczenie, int $uzytkownikId, int $zdobyteXp, int $streak). Zaloguj obie wartości tuż przed wywołaniem. Sygnaturę funkcji sprawdzisz w includes/funkcje_bazy.php.

POMOC
error_log('[debug] xp=' . $zadanie->pobierzXp() . ' streak=' . $uzytkownik->pobierzStreak());

Pytanie kontrolne do siebie: dlaczego PHP nie zgłosiło żadnego błędu, skoro argumenty poszły w złej kolejności?

ROZWIĄZANIE

Log pokazuje np. xp=20 streak=6, a w bazie XP rośnie o 6, seria skacze na 20. Oba parametry są typu int, więc PHP nie ma jak zauważyć zamiany — to błąd, którego żaden mechanizm języka nie wykryje, bo kod jest poprawny, tylko robi nie to, co trzeba.

Stąd wniosek na przyszłość: typy chronią przed pomyłką tylko wtedy, gdy się różnią. Przy kilku parametrach tego samego typu pomagają argumenty nazwane (znasz je z Lekcji 15):

zapiszPostepUzytkownika($polaczenie, uzytkownikId: $uzytkownikId, zdobyteXp: $zadanie->pobierzXp(), streak: $uzytkownik->pobierzStreak());

Przywróć poprawną kolejność argumentów i usuń tymczasowe error_log(). Wywołanie z argumentami nazwanymi możesz zostawić — jest poprawne i czytelniejsze.

8. diagnostyka.php i stopka

diagnostyka.php celowo nie dołącza config/config.php (od Lekcji 10 ma działać nawet wtedy, gdy projekt jest zepsuty). Oznacza to, że nie działa w niej ani tryb błędów, ani handler — i tak ma zostać.

  1. W $wymaganePliki dopisz 'includes/funkcje_bledow.php' i 'logs/.htaccess' (razem 25 ścieżek). Samego logs/taskquest.log nie dopisuj — ten plik powstaje dopiero przy pierwszym błędzie, więc jego brak nie jest usterką.
  2. Pod sekcją „Avatary” dodaj sekcję „Log błędów” sprawdzającą, czy katalog logs istnieje i czy da się do niego pisać — tak samo jak przy uploads/avatary w Lekcji 21 (is_dir(), is_writable(), ścieżka __DIR__ . '/logs').
  3. Sekcja nie pokazuje zawartości logu. diagnostyka.php jest dostępna publicznie, a w logu są nazwy kolumn i ścieżki serwera.
  4. W includes/footer.php zmień numer wersji na TaskQuest 2.3.

9. Testy

Test 1 — regresja

Zaloguj się jako Kama_2026 / KamaQuest26. Sprawdź po kolei: lista zadań, filtry widoku, „Zrobione!” (XP i seria rosną poprawnie), „Usuń”, dodanie zadania, zmiana avatara, wylogowanie, rejestracja nowego konta. Wszystko ma działać tak samo jak po Lekcji 22.

Test 2 — awaria bazy, tryb rozwojowy

Zmień w config/config.php stałą BAZA_NAZWA na taskquest_nie_ma. Odśwież index.php.

Oczekiwane: ramka „Nie udało się wczytać danych: Nie udało się połączyć z bazą danych.”
W logu:     [index.php — dane gracza i zadania] Exception: Nie udało się połączyć z bazą danych. (...)

Zwróć uwagę, że komunikat nie zawiera nazwy konta bazy — tak działa polaczZBaza() od Lekcji 18. Przywróć taskquest.

Test 3 — awaria bazy, tryb produkcyjny

Powtórz Test 2 z TRYB_ROZWOJOWY ustawionym na false.

Oczekiwane na stronie: „Coś się popsuło po naszej stronie. Spróbuj jeszcze raz za chwilę.”
Oczekiwane w logu:     ten sam wpis co w Teście 2

Test 4 — błąd zapytania

Przy TRYB_ROZWOJOWY = false zepsuj nazwę kolumny (tytul → tytul2) i odśwież index.php.

Na stronie: ogólne zdanie, bez nazwy kolumny
W logu:     mysqli_sql_exception: Unknown column 'tytul2' in 'field list'

To jest cel całej lekcji: informacja dotarła do Ciebie, ale nie do gracza. Przywróć tytul i true.

Test 5 — niezłapany TypeError

Zmień w dodaj-zadanie.php name="tytul" na name="tytul[]" i wyślij formularz.

Oczekiwane: strona awarii (nie biała strona, nie Fatal error ze ścieżką)
W logu:     [niezłapany wyjątek] TypeError: trim(): Argument #1 ...

Sprawdź w obu trybach i przywróć name="tytul".

Test 6 — log niedostępny z przeglądarki

http://localhost/taskquest/logs/taskquest.log → 403 Forbidden. Dla porównania http://localhost/taskquest/index.php ma działać normalnie.

Test 7 — diagnostyka

diagnostyka.php: 25 ścieżek [OK], sekcja „Log błędów” pokazuje, że katalog istnieje i jest zapisywalny.

Test 8 — nietknięta walidacja

Wyślij formularz dodawania zadania z pustym tytułem i XP = 500.

Oczekiwane: ramka „Popraw formularz:” z dwoma komunikatami — tak jak dotąd
W logu:     nic

Błąd użytkownika to nie awaria. Gdyby w logu pojawiał się wpis za każdym razem, gdy ktoś się pomyli w formularzu, log przestałby być przydatny.

10. Obowiązkowe eksperymenty

Eksperyment 1 — catch nie łapi wszystkiego

W index.php, wewnątrz bloku try odpowiedzialnego za dane z bazy, dopisz jako pierwszą linię intdiv(1, 0);. Odśwież stronę.

Pytanie: dlaczego nie zobaczyłaś ramki „Nie udało się wczytać danych”, mimo że błąd wystąpił dokładnie wewnątrz try? Które rozwiązanie z dzisiejszej lekcji go obsłużyło? Usuń linię.

Eksperyment 2 — dwa rodzaje błędu składni

Ustaw TRYB_ROZWOJOWY = false.

  1. Usuń średnik w dowolnej linii pliku includes/funkcje_zadan.php. Odśwież index.php → biała strona, ale w logu jest wpis PHP Parse error: .... Napraw.
  2. Usuń średnik w dowolnej linii index.php. Odśwież → tym razem komunikat najpewniej pojawi się na stronie. Napraw.

Wyjaśnij różnicę. Wskazówka: w którym momencie PHP wykonuje ini_set('display_errors', '0') z config.php, a w którym czyta plik index.php? Przywróć true.

Eksperyment 3 — log, którego nie ma

Zmień nazwę katalogu logs na logi (nie zmieniając config.php). Wywołaj dowolną awarię z Testu 2.

Pytanie: czy dostałaś jakąkolwiek informację o tym, że zapis do logu się nie udał? Co to znaczy w praktyce dla aplikacji na serwerze i jak można się przed tym zabezpieczyć? Przywróć nazwę logs.

Eksperyment 4 — var_dump() w miejscu, gdzie go nie widać

W usun-zadanie.php dopisz tuż przed przekierowaniem var_dump($zadanieId);. Usuń dowolne zadanie.

Pytanie: gdzie podział się wypisany tekst? Zamień var_dump() na error_log('[debug] id=' . $zadanieId); i powtórz. Sformułuj regułę: kiedy używasz var_dump(), a kiedy error_log()? Usuń obie linie.

Eksperyment 5 — „headers already sent”

W dodaj-zadanie.php dopisz echo 'test'; przed blokiem if ($_SERVER['REQUEST_METHOD'] === 'POST'). Dodaj zadanie.

Zamiast przekierowania zobaczysz ostrzeżenie o nagłówkach. Wyjaśnij, dlaczego jeden echo psuje header('Location: ...') i dlaczego w naszych stronach z formularzem cała logika jest nad require_once header.php. Usuń echo.

11. ROZWIĄZANIE — wersja referencyjna

Pliki niewymienione poniżej (klasy, header.php, funkcje_grywalizacji.php, funkcje_terminow.php, funkcje_zadan.php, funkcje_tekstowe.php, funkcje_sesji.php, funkcje_avatara.php, funkcje_bazy.php, sql/taskquest.sql, uploads/avatary/.htaccess) są identyczne jak po Lekcji 22.

ROZWIĄZANIE

logs/.htaccess (nowy plik)

# TaskQuest (L23): log błędów nie jest do oglądania przez przeglądarkę.
Require all denied

config/config.php — zmiany

Na górze pliku, pod <?php:

// --- tryb pracy aplikacji (L23) ---
// true  = Twój komputer: błędy widać na stronie
// false = serwer: gracz widzi tylko ogólny komunikat, szczegóły idą do logu
define('TRYB_ROZWOJOWY', true);

define('PLIK_LOGU', dirname(__DIR__) . '/logs/taskquest.log');

error_reporting(E_ALL);
ini_set('log_errors', '1');
ini_set('error_log', PLIK_LOGU);

if (TRYB_ROZWOJOWY) {
    ini_set('display_errors', '1');
} else {
    ini_set('display_errors', '0');
}

Na końcu pliku, pod dotychczasowymi stałymi:

// --- obsługa błędów dla całej aplikacji (L23) ---
require_once dirname(__DIR__) . '/includes/funkcje_bledow.php';
set_exception_handler('pokazAwarie');

Pozostałe stałe (XP_NA_POZIOM, CZAS_PAMIETANIA_WIDOKU, BAZA_*, KATALOG_AVATAROW, MAKS_ROZMIAR_AVATARA, DOZWOLONE_ROZSZERZENIA_AVATARA) bez zmian.

includes/funkcje_bledow.php (nowy plik)

<?php

// Funkcje obsługi błędów TaskQuest (Lekcja 23).
// Plik wymaga config/config.php (stałe TRYB_ROZWOJOWY i PLIK_LOGU).

function zapiszBladDoLogu(Throwable $wyjatek, string $gdzie): void
{
    $opis = get_class($wyjatek) . ': ' . $wyjatek->getMessage()
        . ' (' . $wyjatek->getFile() . ':' . $wyjatek->getLine() . ')';

    error_log('[' . $gdzie . '] ' . $opis);
}

function komunikatAwarii(Throwable $wyjatek): string
{
    if (TRYB_ROZWOJOWY) {
        return $wyjatek->getMessage();
    }

    return 'Coś się popsuło po naszej stronie. Spróbuj jeszcze raz za chwilę.';
}

function pokazAwarie(Throwable $wyjatek): void
{
    zapiszBladDoLogu($wyjatek, 'niezłapany wyjątek');

    if (!headers_sent()) {
        http_response_code(500);
    }

    echo '<h1>TaskQuest</h1>';
    echo '<p>Coś się popsuło po naszej stronie. Spróbuj jeszcze raz za chwilę.</p>';

    if (TRYB_ROZWOJOWY) {
        echo '<p><strong>' . htmlspecialchars(get_class($wyjatek)) . ':</strong> '
            . htmlspecialchars($wyjatek->getMessage()) . '</p>';
        echo '<p>' . htmlspecialchars($wyjatek->getFile()) . ':' . $wyjatek->getLine() . '</p>';
        echo '<pre>' . htmlspecialchars($wyjatek->getTraceAsString()) . '</pre>';
    }

    echo '<p><a href="index.php">← Wróć do TaskQuest</a></p>';
}

Siedem bloków catch — wzorzec

} catch (Exception $wyjatek) {
    zapiszBladDoLogu($wyjatek, 'index.php — dane gracza i zadania');
    $bladBazy = komunikatAwarii($wyjatek);
}

Etykiety $gdzie i zmienne — tabela w sekcji 5.3. W oznacz-wykonane.php i usun-zadanie.php dochodzi zmienna $bladAkcji, a ramka awarii wypisuje htmlspecialchars($bladAkcji) zamiast htmlspecialchars($wyjatek->getMessage()).

diagnostyka.php — zmiany

$wymaganePliki + 'includes/funkcje_bledow.php' i 'logs/.htaccess' (25 ścieżek). Nowa sekcja pod „Avatary”:

<h2>Log błędów</h2>
<?php
$katalogLogow = __DIR__ . '/logs';

if (!is_dir($katalogLogow)) {
    echo '<p>[BRAK] Katalog logs/ nie istnieje — utwórz go ręcznie.</p>';
} elseif (!is_writable($katalogLogow)) {
    echo '<p>[BRAK] Katalog logs/ istnieje, ale nie można do niego pisać.</p>';
} else {
    echo '<p>[OK] Katalog logs/ istnieje i jest zapisywalny.</p>';
}
?>

includes/footer.php

        <p>TaskQuest 2.3</p>

12. Co zmieniliśmy w TaskQuest?

Zostaje

wszystko z Lekcji 22: konta z hasłem, rejestracja, token CSRF, escapowanie, .htaccess w uploads/
zadania w bazie, XP, seria dni, avatary, ranking, filtry widoku, komunikaty jednorazowe
walidacja danych od użytkownika jako tablica komunikatów (błąd użytkownika to nie awaria)
catch (Exception ...) w siedmiu miejscach — łapiemy przewidziane awarie, nie błędy programisty
polaczZBaza() nadal ukrywa szczegóły połączenia
diagnostyka.php nadal bez config.php i bez sesji

Dochodzi

config/config.php:
    TRYB_ROZWOJOWY, PLIK_LOGU
    error_reporting(E_ALL), display_errors zależne od trybu, log_errors, error_log
    require_once funkcje_bledow.php + set_exception_handler('pokazAwarie')
includes/funkcje_bledow.php:
    zapiszBladDoLogu(Throwable $wyjatek, string $gdzie): void
    komunikatAwarii(Throwable $wyjatek): string
    pokazAwarie(Throwable $wyjatek): void
logs/ — katalog na log aplikacji (tworzony ręcznie)
logs/.htaccess — Require all denied
$bladAkcji w oznacz-wykonane.php i usun-zadanie.php

Nowe mechanizmy PHP: error_reporting(), ini_set(), ini_get(), error_log(), set_exception_handler(), headers_sent(), http_response_code(), get_class(), interfejs Throwable i klasy Error (TypeError, DivisionByZeroError); w brudnopisie var_dump(), print_r().

Zmieniamy

  • siedem bloków catch: zapis do logu + komunikat zależny od trybu zamiast $wyjatek->getMessage(),
  • diagnostyka.php: 25 ścieżek, sekcja „Log błędów”,
  • stopka: 2.3.

Usuwamy

Nic. Lekcja 23 nic nie odbiera aplikacji.

Co nowego potrafi TaskQuest po tej lekcji?

Po Lekcji 23 TaskQuest pracuje w dwóch trybach. Na Twoim komputerze pokazuje pełne szczegóły każdego błędu, a na serwerze — jedno ogólne zdanie, bez nazw kolumn i ścieżek do plików. Niezależnie od trybu każda awaria zostawia wpis w logs/taskquest.log, którego nie da się odczytać przez przeglądarkę. Błąd, którego nikt nie złapał, kończy się stroną awarii zamiast białego ekranu.

13. Zadania końcowe — rozbuduj TaskQuest samodzielnie

Nie są częścią wersji referencyjnej.

A. Numer zgłoszenia. W trybie produkcyjnym gracz widzi ogólne zdanie i nie ma czym się posłużyć, pisząc do Ciebie. Wygeneruj krótki identyfikator awarii (np. bin2hex(random_bytes(4)) — znasz to z Lekcji 22), dopisz go do wpisu w logu i pokaż graczowi: „Numer zgłoszenia: 3f9a1c07”. Zastanów się: która z trzech funkcji powinna go tworzyć, żeby log i komunikat miały ten sam numer?

B. Pole wysłane jako tablica. Popraw prawdziwą przyczynę błędu z sekcji 2: przy odczycie pól formularza sprawdzaj is_string() (tak jak sprawdzTokenCsrf() sprawdza token od Lekcji 22). Które pliki i które linie trzeba zmienić? Czy to ma być nowy komunikat w $bledy, czy ciche potraktowanie takiego pola jako pustego? Uzasadnij wybór.

C. Warning też do logu — i co dalej. Poczytaj o set_error_handler(). Napisz funkcję, która zamienia ostrzeżenia PHP na wyjątki (ErrorException), i włącz ją w config.php. Sprawdź na move_uploaded_file() przy usuniętym katalogu uploads/avatary/ (sytuacja z Lekcji 21). Potem odpowiedz: czy to jest dobry pomysł dla TaskQuest? Co się stanie z ostrzeżeniami, które dziś aplikacja spokojnie ignoruje?

D. Nie każdy błąd jest wyjątkiem. Handler z sekcji 6 nie obejmuje przekroczenia limitu pamięci ani czasu wykonania. Poczytaj o register_shutdown_function() i error_get_last() i dopisz obsługę takiego przypadku. Jak sprawdzisz, że działa, nie czekając 30 sekund? (Wskazówka: ini_set('memory_limit', ...) w brudnopisie i duża tablica.)

E. Xdebug. Zainstaluj Xdebug w XAMPP i połącz go z VS Code. Postaw pułapkę (breakpoint) w oznacz-wykonane.php i przejdź przez kod krok po kroku, oglądając wartości zmiennych. Porównaj z metodą error_log() z sekcji 7 — co jest szybsze przy jednym sprawdzeniu, a co przy szukaniu błędu, którego nie umiesz zlokalizować?

Sprzątanie

  1. Usuń brudnopis.php.
  2. Sprawdź, że przywrócone są: nazwa kolumny tytul w wczytajZadaniaUzytkownika(), name="tytul" w dodaj-zadanie.php, BAZA_NAZWA = taskquest, TRYB_ROZWOJOWY = true, poprawna kolejność argumentów w zapiszPostepUzytkownika(...), nazwa katalogu logs.
  3. Usuń wszystkie tymczasowe error_log(), var_dump() i echo 'test'; z eksperymentów (Ctrl+Shift+F → debug, potem var_dump).
  4. Uruchom ponownie sql/taskquest.sql (XP i seria Kamy zmieniły się podczas Testu 1 i zadania z sekcji 7) i opróżnij uploads/avatary/ z plików avatar_... — .htaccess zostaje.
  5. Opróżnij logs/taskquest.log (zaznacz wszystko, usuń, zapisz) — plik ma zostać, zawartość nie.
  6. Zaloguj się jako Kama_2026 / KamaQuest26. diagnostyka.php — wszystko [OK].

Do samodzielnego sprawdzenia

[ ] Nadal rozwijam ten sam projekt TaskQuest (nie zaczynam nowego).
[ ] Rozpoznaję po komunikacie, czy to Parse error, Fatal error, Warning czy Deprecated.
[ ] Potrafię wskazać w komunikacie plik i linię i wiem, dlaczego to nie zawsze miejsce powstania błędu.
[ ] Wiem, czym różni się Exception od Error i czego nie łapie catch (Exception ...).
[ ] Umiem ustawić tryb rozwojowy i produkcyjny i wiem, dlaczego error_reporting(E_ALL) zostaje w obu.
[ ] Wiem, dlaczego log błędów nie może być dostępny przez przeglądarkę.
[ ] Potrafię dopisać własny wpis do logu funkcją error_log().
[ ] Wiem, dlaczego do logu nie trafia stos wywołań.
[ ] Wiem, co robi set_exception_handler() i czego nie obejmuje.
[ ] Wiem, kiedy var_dump() nic mi nie pokaże i czego użyć zamiast niego.
[ ] Usunęłam/usunąłem brudnopis i wszystkie tymczasowe wpisy debugujące.

Pytania kontrolne

  1. Kolega wstawił na górze pliku echo "Start";, a niżej ma błąd składni. Twierdzi, że „przynajmniej początek strony się wyświetli”. Czy ma rację? Uzasadnij.
  2. W pliku jest try { $wynik = 10 / $dzielnik; } catch (Exception $wyjatek) { echo 'Błąd'; }. Co zobaczy użytkownik, gdy $dzielnik wynosi 0? Jak to poprawić, nie używając catch (Throwable ...)?
  3. Koleżanka ustawiła na serwerze error_reporting(0), „żeby użytkownicy nie widzieli błędów”. Wymień dwa problemy, jakie sobie w ten sposób stworzyła, i podaj poprawne ustawienie.
  4. Czym różni się ini_set('display_errors', '0') od ini_set('log_errors', '0')? Które z tych ustawień w TaskQuest zmienia się razem z TRYB_ROZWOJOWY, a które nie — i dlaczego?
  5. Dlaczego zapiszBladDoLogu() przyjmuje Throwable, a nie Exception? Podaj konkretny przypadek z tej lekcji, w którym ten wybór ma znaczenie.
  6. Gracz dostał komunikat „Coś się popsuło po naszej stronie” i pisze do Ciebie o 22:00. Opisz krok po kroku, co robisz, żeby ustalić przyczynę.
  7. set_exception_handler() nie zadziała dla trzech rodzajów problemów. Wymień je i powiedz, co obsługuje je zamiast handlera.
  8. Znajdź co najmniej trzy błędy: function zapiszBlad(Exception $wyjatek): string { error_log(date('Y-m-d H:i') . ' ' . $wyjatek->getTraceAsString()); echo '<p>' . $wyjatek->getMessage() . '</p>'; return $wyjatek->getFile(); }
  9. W dodaj-zadanie.php pusty tytuł daje komunikat „Tytuł jest wymagany.”, a awaria bazy — wpis w logu. Dlaczego pusty tytuł nie trafia do logu? Co by się stało z logiem, gdybyśmy zapisywały tam każdy błąd walidacji?
  10. pokazAwarie() wypisuje własny prosty HTML zamiast użyć header.php i footer.php, przez co strona awarii nie ma stylów TaskQuest. Podaj powód tej decyzji i jedną sytuację, w której użycie header.php skończyłoby się źle.