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