<? phpukraine СПІВБЕСІДИ
Пошук по платформі
CORE PHP · JUNIOR ЧАСТО ПИТАЮТЬ

Як влаштована обробка помилок у PHP: винятки, Error, Throwable і finally?

З PHP 7 усе, що можна кинути, реалізує інтерфейс `Throwable`, під яким дві незалежні гілки: `Exception` (очікувані ситуації рантайму) і `Error` (помилки програміста: `TypeError`, `ValueError`, `DivisionByZeroError`). `finally` виконується завжди, окрім `exit()` і фатального завершення, а warnings лишилися для ситуацій, які PHP історично вміє пережити, і перетворюються на винятки через `set_error_handler`.

Чому `catch (Exception $e)` не спіймав `TypeError`?
Ділення на нуль у PHP 8 повертає false, кидає warning чи виняток?
Що поверне метод, якщо `return` є і в `try`, і в `finally`?
Навіщо писати власний клас винятку, якщо є `RuntimeException` з повідомленням?
Warning не зупиняє скрипт. Як зробити так, щоб зупиняв?
Throwable Exception Error TypeError finally set_error_handler ErrorException PHP 8

До PHP 7 картина була рвана: винятки жили самі по собі, фатальні помилки движка не ловилися взагалі, і try/catch навколо виклику з неправильним типом аргументу нічого не давав. У PHP 7 усе звели під один інтерфейс Throwable. Кинути можна тільки об'єкт, який його реалізує, причому реалізувати цей інтерфейс напряму у своєму класі не вийде: PHP дозволяє лише успадкування від Exception або Error. Набір методів в обох класах однаковий (getMessage, getCode, getFile, getLine, getPrevious, getTrace, getTraceAsString), різниця між ними суто смислова. Exception описує те, що застосунок мусить передбачити: файл не відкрився, платіж відхилено, JSON зіпсутий. Error описує зламаний код: TypeError при невідповідності типу, ValueError (PHP 8.0) при неможливому значенні аргументу, ArgumentCountError при браку аргументів, DivisionByZeroError при 1/0 чи % з нулем, UnhandledMatchError, коли match не знайшов гілки.

Звідси і головна пастка на співбесіді. catch (Exception $e) не ловить TypeError, бо гілки паралельні, а не вкладені. Так зроблено свідомо: помилку програміста не варто перехоплювати разом зі збоєм мережі. У бізнес-логіці ловлять конкретні класи винятків, а catch (Throwable $e) лишають для однієї-двох точок на межі застосунку, де треба залогувати все й віддати 500 замість білого екрана. PHP 8.0 суттєво посунув межу між гілками: внутрішні функції, які раніше видавали warning і повертали null, тепер кидають TypeError, тому код, що мовчки їхав далі на битих даних, у восьмій версії чесно падає.

finally виконується після try і після будь-якого catch, незалежно від того, чим блок завершився: нормальним проходом, return, break, continue чи кинутим винятком. Саме тому туди кладуть звільнення ресурсів. Години налагодження з'їдають дві його особливості. Перша: return усередині try спочатку обчислює значення, потім виконує finally, і якщо finally теж має return, назовні піде саме він, а виняток, який летів нагору, буде проковтнутий безслідно. Друга: виняток, кинутий у finally, заміщає поточний, причому старий PHP автоматично покладе в getPrevious(). Тримайте в finally тільки те, що не кидає й не повертає. І пам'ятайте про межу: exit(), die() та фатальна помилка завершують процес, не виконуючи finally.

Warning-и нікуди не поділись, бо велика частина стандартної бібліотеки писалася до появи винятків і сигналізує про збій поверненням false. fopen, file_get_contents, unlink, preg_match роблять саме так, і перевести їх на винятки означало б зламати екосистему за одну ніч. Тому діагностику перехоплюють самотужки: set_error_handler отримує рівень, повідомлення, файл і рядок (п'ятий параметр $errcontext прибрали в PHP 8.0) і кидає ErrorException. Так влаштовано в Laravel, у Symfony і майже в кожному продакшен-застосунку. Легко пропустити два моменти: handler викликається незалежно від налаштування error_reporting, тому маску треба перевіряти вручну, а повернення false з handler-а передає обробку стандартному механізму PHP. Фатальні помилки цим не покриваються, для них потрібен register_shutdown_function з error_get_last().

Власні винятки має сенс заводити тоді, коли хтось вище по стеку ловитиме саме їх. За назвою класу catch розрізняє ситуації, а типізовані властивості всередині позбавляють обробник потреби парсити рядок повідомлення. Зручна структура для модуля: спільний інтерфейс (interface BillingError extends Throwable), від нього конкретні класи. Тоді один catch покриває весь модуль, інший - окремий випадок. Коли переозначаєте конструктор, залишайте параметр ?Throwable $previous і передавайте його в parent::__construct(): без нього обривається ланцюжок причин і в логах лишається верхнє повідомлення без початкової помилки. З синтаксису, який спрощує життя: catch (A|B $e) доступний з PHP 7.1, catch (JsonException) без змінної та throw як вираз ($user = find($id) ?? throw new NotFound()) - з PHP 8.0.

declare(strict_types=1);

/** Власний виняток шару імпорту: несе контекст, а не лише текст. */
final class ImportFailed extends RuntimeException
{
    public function __construct(
        public readonly string $path,
        public readonly int $row,
        ?Throwable $previous = null,   // ланцюжок причин не губимо
    ) {
        parent::__construct("Рядок {$row} у {$path} не імпортувався", 0, $previous);
    }
}

// Warning і notice перетворюємо на винятки, інакше код піде далі з битими даними
set_error_handler(static function (int $severity, string $msg, string $file, int $line): bool {
    // handler кличуть незалежно від error_reporting, тож маску перевіряємо самі
    if ((error_reporting() & $severity) === 0) {
        return false;              // подавлене через @ або вимкнене, віддаємо назад PHP
    }
    throw new ErrorException($msg, 0, $severity, $file, $line);
});

/** @return array<string, mixed> */
function importRow(string $path, int $row, string $json): array
{
    $handle = fopen($path, 'rb');  // false тихо вже не повернеться: warning стане винятком
    try {
        // JSON_THROW_ON_ERROR (PHP 7.3+) замість перевірки json_last_error()
        return json_decode($json, true, 512, JSON_THROW_ON_ERROR);
    } catch (JsonException|ErrorException $e) {
        throw new ImportFailed($path, $row, $e);
    } finally {
        fclose($handle);           // спрацює і на return, і на throw
    }
}

try {
    importRow('/tmp/rows.json', 12, '{зламано');
} catch (ImportFailed $e) {
    echo $e->getMessage(), ' | причина: ', $e->getPrevious()?->getMessage();
} catch (Error $e) {               // TypeError, ValueError: це баги, а не погані дані
    echo 'Помилка в коді: ', $e->getMessage();
}
Що `Exception` і `Error` не успадковують один одного: спільний у них лише інтерфейс `Throwable`, тому `catch (Exception)` пропускає `TypeError` повз себе.
Що `Error` сигналізує про баг у коді (не той тип, неможливе значення, виклик методу на `null`), а `Exception` про ситуацію, яку код зобов'язаний передбачити: файл не відкрився, API відповів 500, валідація не пройшла.
Що PHP 8.0 перевів велику частину колишніх warning-ів у `Error`: внутрішні функції з неправильним типом аргументу кидають `TypeError`, `1/0` кидає `DivisionByZeroError`, невідповідність у `match` дає `UnhandledMatchError`.
Що `finally` виконується і після `return`, і після `throw`, а `return` у `finally` перекриває результат `try` та проковтує вже кинутий виняток.
Що `set_error_handler` перехоплює warning/notice/deprecated, але не фатальні помилки й не помилки парсингу, і що стандартна практика - обгортати їх в `ErrorException`.
Що власний виняток створюють заради того, щоб виклик міг його відрізнити в `catch` і дістати з нього контекст, а не заради красивої назви.
Ловити все через `catch (Exception $e)` і дивуватись 500-й на `TypeError`: для повного покриття потрібен `catch (Throwable $e)`.
Писати `catch (Throwable $e)` у бізнес-логіці й ковтати `Error`: помилка програміста маскується під збій даних, а баг живе в проді місяцями.
Оголошувати `class MyException implements Throwable`: PHP забороняє реалізовувати цей інтерфейс напряму, треба успадкувати `Exception` або `Error`.
Переозначити конструктор винятку як `__construct(string $message)` і втратити третій параметр `?Throwable $previous`: ланцюжок причин обривається, `getPrevious()` дає `null`.
Ставити `return` у `finally`, щоб «підстрахуватись»: він тихо перекриває і значення з `try`, і виняток, який летів нагору.
Розраховувати, що `finally` відпрацює після `exit()`, фатальної помилки або `E_ERROR`: у цих випадках він не виконується.
Робити `try { ... } catch (Exception $e) {}` з порожнім тілом замість `catch` за конкретним класом: далі за кодом ніхто не дізнається, що саме зламалось.
ПОРАДА

Одна фраза, яка закриває питання: «`Throwable` - це дах, під ним `Exception` для передбачених збоїв і `Error` для багів; ловлять зазвичай `Exception`, а `Throwable` тільки на верхньому рівні застосунку». Далі додайте, що `finally` виконується завжди, крім `exit()` і фатала, і що warnings перетворюють на винятки через `set_error_handler` з `ErrorException`.

оновлено 6 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

З PHP 7 корінь ієрархії - інтерфейс `Throwable`, від якого йдуть дві незалежні гілки: `Exception` і `Error`. `TypeError` (як і `ValueError`, `ArgumentCountError`, `DivisionByZeroError`) успадковує `Error`, тому `catch (Exception $e)` його пропускає. Щоб зловити обидві гілки, потрібен `catch (Throwable $e)`.