Wyjątki

🎯 Po tej lekcji obsłużysz wyjątek przez try/catch, użyjesz finally i stworzysz własną klasę wyjątku rozszerzającą Exception.

Do tej pory w kursie sygnalizowaliśmy błędy przez return false albo zwracanie tablicy z błędami. Działa, ale ma ograniczenia: kod wywołujący musi pamiętać o sprawdzeniu wartości zwracanej, a błąd może zostać po cichu zignorowany. PHP oferuje obiektowy mechanizm obsługi błędów: wyjątki (ang. exceptions).

Czym jest wyjątek?

Wyjątek to obiekt sygnalizujący, że coś poszło nie tak. Rzucasz go przez throw, a łapiesz przez try/catch. Gdy wyjątek zostanie rzucony i nikt go nie złapie — skrypt się zatrzymuje (Fatal Error).

throw i try/catch — podstawy

<?php
declare(strict_types=1);

function podziel(float $a, float $b): float
{
    if ($b === 0.0) {
        throw new \InvalidArgumentException('Dzielnik nie może być zerem.');
    }
    return $a / $b;
}

try {
    echo podziel(10.0, 2.0); // wynik: 5
    echo podziel(10.0, 0.0); // rzuca wyjątek
    echo 'Tu już nie dojdziemy';
} catch (\InvalidArgumentException $e) {
    echo 'Błąd: ' . $e->getMessage(); // wynik: Błąd: Dzielnik nie może być zerem.
}

Działanie:

  1. throw natychmiast przerywa bieżącą funkcję i szuka odpowiedniego catch.
  2. PHP przeszukuje bloki catch od góry — wykonuje pierwszy pasujący.
  3. Po obsłużeniu catch kod kontynuuje za blokiem try/catch.

Metody obiektu wyjątku

Klasa \Exception (i jej podklasy) dostarcza wiele przydatnych metod:

<?php
declare(strict_types=1);

try {
    throw new \RuntimeException('Coś się posypało.', 500);
} catch (\RuntimeException $e) {
    echo $e->getMessage(); // wynik: Coś się posypało.
    echo $e->getCode();    // wynik: 500
    echo $e->getFile();    // wynik: ścieżka do bieżącego pliku
    echo $e->getLine();    // wynik: numer linii throw
}

finally — kod wykonywany zawsze

Blok finally wykona się niezależnie od tego, czy wyjątek wystąpił czy nie. Przydaje się do sprzątania zasobów (zamknięcia pliku, połączenia z bazą):

<?php
declare(strict_types=1);

function wczytajPlik(string $sciezka): string
{
    $uchwyt = fopen($sciezka, 'r');
    if ($uchwyt === false) {
        throw new \RuntimeException("Nie można otworzyć pliku: $sciezka");
    }

    try {
        $zawartosc = fread($uchwyt, filesize($sciezka));
        if ($zawartosc === false) {
            throw new \RuntimeException("Błąd odczytu pliku: $sciezka");
        }
        return $zawartosc;
    } finally {
        fclose($uchwyt); // wykona się zawsze — plik zostanie zamknięty
    }
}

try {
    $tekst = wczytajPlik('dane.txt');
    echo $tekst;
} catch (\RuntimeException $e) {
    echo 'Błąd: ' . $e->getMessage();
}
💡 finally a return: Jeśli blok try lub catch zawiera return, finally i tak się wykona — przed faktycznym zwróceniem wartości. To gwarancja sprzątania.

Łapanie wielu typów wyjątków

Możesz mieć wiele bloków catch — każdy obsługuje inny typ. PHP wykona pierwszy pasujący:

<?php
declare(strict_types=1);

function pobierzDane(int $id): string
{
    if ($id <= 0) {
        throw new \InvalidArgumentException("ID musi być dodatnie, podano: $id");
    }
    if ($id > 1000) {
        throw new \RuntimeException("Nie znaleziono rekordu o ID: $id");
    }
    return "Dane dla ID $id";
}

try {
    echo pobierzDane(-1);
} catch (\InvalidArgumentException $e) {
    echo 'Błędny argument: ' . $e->getMessage();
    // wynik: Błędny argument: ID musi być dodatnie, podano: -1
} catch (\RuntimeException $e) {
    echo 'Błąd runtime: ' . $e->getMessage();
} catch (\Exception $e) {
    echo 'Nieznany błąd: ' . $e->getMessage();
}

Możesz też złapać kilka typów w jednym catch przez |:

} catch (\InvalidArgumentException | \LengthException $e) {
    echo 'Błąd logiki: ' . $e->getMessage();
}
⚠️ Kolejność catch ma znaczenie: Umieszczaj bardziej szczegółowe wyjątki (np. \InvalidArgumentException) PRZED ogólnymi (np. \Exception). PHP wykonuje pierwszy pasujący — ogólny catch na początku "pochłonie" wszystko, zanim dotrzesz do szczegółowych.

Hierarchia wbudowanych wyjątków

PHP dostarcza bogatą hierarchię wyjątków:

\Throwable
├── \Error          (błędy wewnętrzne PHP: TypeError, ParseError, ...)
└── \Exception
    ├── \LogicException        (błędy logiczne — można było zapobiec)
    │   ├── \BadFunctionCallException
    │   │   └── \BadMethodCallException
    │   ├── \DomainException
    │   ├── \InvalidArgumentException   ← zły argument wejściowy
    │   ├── \LengthException
    │   └── \OutOfRangeException
    └── \RuntimeException      (błędy w czasie działania programu)
        ├── \OutOfBoundsException
        ├── \OverflowException
        ├── \RangeException
        ├── \UnderflowException
        └── \UnexpectedValueException

Wybieraj typ wyjątku odpowiedni do sytuacji: \InvalidArgumentException gdy argument jest nieprawidłowy, \RuntimeException gdy coś poszło nie tak w czasie działania.

Własne klasy wyjątków

Tworzysz własny wyjątek przez rozszerzenie \Exception lub innej klasy z hierarchii:

<?php
declare(strict_types=1);

// Bazowy wyjątek dla domeny
class BledSklepuException extends \RuntimeException {}

// Specyficzne błędy
class ProduktNiedostepnyException extends BledSklepuException
{
    public function __construct(string $nazwaProductu)
    {
        parent::__construct("Produkt '$nazwaProductu' jest niedostępny.");
    }
}

class NiewystarczajaceSaldoException extends BledSklepuException
{
    public function __construct(float $wymagane, float $dostepne)
    {
        parent::__construct(
            sprintf('Wymagane: %.2f zł, dostępne: %.2f zł.', $wymagane, $dostepne)
        );
    }
}

// Użycie
function kupProdukt(string $nazwa, float $cena, float $saldo): void
{
    if ($nazwa === 'Arbuz') {
        throw new ProduktNiedostepnyException($nazwa);
    }
    if ($saldo < $cena) {
        throw new NiewystarczajaceSaldoException($cena, $saldo);
    }
    echo "Kupiono: $nazwa";
}

try {
    kupProdukt('Arbuz', 12.99, 50.0);
} catch (ProduktNiedostepnyException $e) {
    echo 'Brak towaru: ' . $e->getMessage();
    // wynik: Brak towaru: Produkt 'Arbuz' jest niedostępny.
} catch (NiewystarczajaceSaldoException $e) {
    echo 'Brak środków: ' . $e->getMessage();
} catch (BledSklepuException $e) {
    echo 'Ogólny błąd sklepu: ' . $e->getMessage();
}

Własne wyjątki dają dwie korzyści:

  1. Czytelność — nazwa wyjątku mówi dokładnie co poszło nie tak.
  2. Selektywne łapanie — możesz obsługiwać ProduktNiedostepnyException inaczej niż NiewystarczajaceSaldoException, mimo że oba dziedziczą po BledSklepuException.

Rzucanie wyjątku z innym jako przyczyną

Wyjątek może przyjąć trzeci parametr — „poprzednią przyczynę" (ang. previous). Przydatne gdy konwertujesz niskopoziomowy wyjątek na domenowy:

<?php
declare(strict_types=1);

class BazaDanychException extends \RuntimeException {}

function pobierzUzytkownika(int $id): array
{
    try {
        // symulacja błędu PDO
        throw new \PDOException("Connection refused");
    } catch (\PDOException $e) {
        throw new BazaDanychException(
            "Nie można pobrać użytkownika #$id",
            0,
            $e // poprzednia przyczyna — zachowuje ślad
        );
    }
}

try {
    pobierzUzytkownika(1);
} catch (BazaDanychException $e) {
    echo $e->getMessage();                     // wynik: Nie można pobrać użytkownika #1
    echo $e->getPrevious()?->getMessage();     // wynik: Connection refused
}

Brawo — dotarłeś do końca modułu OOP! Znasz teraz klasy, obiekty, konstruktory, enkapsulację, dziedziczenie, klasy abstrakcyjne, interfejsy, traity, enumy, przestrzenie nazw i wyjątki. W następnym module poznasz Composera — menedżera pakietów PHP, który łączy wszystkie te koncepcje w profesjonalny przepływ pracy.

Sprawdź się

Czy blok finally wykona się, jeśli w bloku try zostanie rzucony wyjątek, który zostanie złapany przez catch?

Co się stanie z niezłapanym wyjątkiem (brak pasującego catch)?

Jak stworzyć własną klasę wyjątku NieprawidlowyWiekException?

Który catch zostanie wykonany dla: throw new \InvalidArgumentException('błąd')?

Ćwiczenie

Napisz klasę Dzielnik z metodą statyczną podziel(float $a, float $b): float. Gdy $b === 0.0, rzuć DivisionByZeroException (własna klasa dziedzicząca po \RuntimeException) z komunikatem 'Nie można dzielić przez zero'. Przetestuj przez try/catch dla poprawnego przypadku, dzielenia przez zero i bloku finally logującego 'Operacja zakończona'.

Pokaż rozwiązanie
<?php
declare(strict_types=1);

class DivisionByZeroException extends \RuntimeException {}

class Dzielnik
{
    public static function podziel(float $a, float $b): float
    {
        if ($b === 0.0) {
            throw new DivisionByZeroException('Nie można dzielić przez zero.');
        }
        return $a / $b;
    }
}

// Test 1: poprawne dzielenie
try {
    $wynik = Dzielnik::podziel(10.0, 2.0);
    echo 'Wynik: ' . $wynik; // wynik: Wynik: 5
} catch (DivisionByZeroException $e) {
    echo 'Błąd: ' . $e->getMessage();
} finally {
    echo 'Operacja zakończona.'; // wynik: Operacja zakończona.
}

// Test 2: dzielenie przez zero
try {
    $wynik = Dzielnik::podziel(10.0, 0.0);
    echo 'Wynik: ' . $wynik;
} catch (DivisionByZeroException $e) {
    echo 'Błąd: ' . $e->getMessage(); // wynik: Błąd: Nie można dzielić przez zero.
} finally {
    echo 'Operacja zakończona.'; // wynik: Operacja zakończona.
}