Глава 26 · С нуля → практика
Ошибки и исключения: что ловить и где
От неверного количества до отказа API: сохраняем причину, освобождаем ресурсы и выбираем границу обработки.
Сообщение PHP и выброшенный объект ошибки: два механизма
Форма содержит неправильный адрес, PHP не нашёл файл, а программист передал строку вместо массива. Это разные ситуации. Начинаем с вопроса: что именно произошло и продолжилась ли программа? Словом «ошибка» часто называют сразу несколько механизмов.
| Событие | Как проявляется | Что делает программа |
|---|---|---|
| Обычное неверное поле формы | Результат проверки: false или список замечаний | Показывает понятное исправление |
| Диагностика E_WARNING / E_DEPRECATED | Сообщение PHP или пользовательского обработчика | Обычно выполнение продолжается |
| Выброшен Exception / Error | Объект передаётся вверх по вызовам | Обычный путь прерывается до подходящего catch |
| Фатальная диагностика | Например, исчерпан доступный ресурс процесса | Скрипт прекращается; catch не универсален |
Не пытайтесь завернуть любой сбой в catch и считать операцию успешной. Сначала читайте контракт вызванного средства: оно может вернуть false, сообщить warning, выбросить исключение или сочетать несколько способов. Открытие файла и проверка формы требуют разных реакций.
Throw меняет путь выполнения: оставшиеся строки пропускаются
- Начали try
- throw создал выход с ошибкой
- Оставшуюся часть try пропустили
- Подходящий catch обработал отказ
- Продолжили после всей конструкции
<?php
echo "Перед проверкой\n";
try {
echo "Проверяем товар\n";
throw new RuntimeException('Товар недоступен');
echo "Эта строка не выполнится\n";
} catch (RuntimeException $error) {
echo 'Отказ: ', $error->getMessage(), "\n";
}
echo "После обработки\n";
Ожидаемый результат
Перед проверкой
Проверяем товар
Отказ: Товар недоступен
После обработки
Try ограничивает участок, catch называет принимаемый класс, а $error получает объект с информацией. New RuntimeException создаёт этот объект; throw передаёт его обработчику. Вызов ->getMessage() читает сообщение. Это небольшой первый объект из стандартной библиотеки: писать собственный класс для опыта не требуется.
После catch PHP не возвращается к пропущенному echo внутри try. Чтобы повторить операцию, нужен новый явно организованный вызов с проверкой допустимости повтора. Один try обязан иметь catch или finally. Throw в PHP 8 является выражением, но бросить можно только Throwable-объект.
<?php
$config = [];
try {
$endpoint = $config['endpoint'] ?? throw new RuntimeException('Не задан адрес API');
echo $endpoint;
} catch (RuntimeException $error) {
echo $error->getMessage(), "\n";
}
Ожидаемый результат
Не задан адрес API
?? выбирает правую часть, когда поля нет или оно равно null. Справа throw прерывает присваивание: $endpoint не получает «значение ошибки». Прежде чем применять сокращение, разберите ?? и условные выражения. Это проверка обязательной настройки, а не валидация самого URL.
Исключение останавливает текущую операцию и передаёт ошибку обработчику
Заказ нельзя собрать из нулевого количества. Расчёт должен остановиться и объяснить вызывающей программе причину, чтобы она не продолжила работу с выдуманным результатом. throw сообщает о неудаче; try ограничивает выполняемую попытку; подходящий catch выбирает реакцию.
- Функция проверяет quantity
- throw прерывает вычисление
- catch получает ошибку
- Внешний код показывает сообщение
new InvalidArgumentException(...) создаёт стандартный объект описания ошибки. Писать свой класс для этого опыта не нужно. $error->getMessage() получает переданный текст. Catch перехватывает указанный тип исключения, а не любую возможную проблему программы.
<?php
function lineTotal($quantity)
{
if ($quantity < 1) {
throw new InvalidArgumentException('Количество должно быть больше нуля');
}
return 150 * $quantity;
}
try {
echo lineTotal(0), "\n";
echo "Заказ рассчитан\n";
} catch (InvalidArgumentException $error) {
echo $error->getMessage(), "\n";
}
echo "Можно исправить ввод\n";
Ожидаемый результат
Количество должно быть больше нуля
Можно исправить ввод
При 0 return не достигается, вызывающий echo не получает число, а следующая инструкция «Заказ рассчитан» пропускается. После обработчика выполняется код ниже. Мы не говорим, что расчёт успешно закончился.
Попробуйте сами. Передайте 2 вместо 0.
Разбор решения
Получите 300, затем «Заказ рассчитан», затем «Можно исправить ввод». Catch не выполняется, потому что исключения не было; последний текст в учебном примере показывает продолжение после всей конструкции.
finally нужен для завершения работы с ресурсом
<?php
try {
echo "Начало\n";
throw new RuntimeException('Поставщик недоступен');
} catch (RuntimeException $error) {
echo 'Обработали: ', $error->getMessage(), "\n";
} finally {
echo "Завершили попытку\n";
}
Ожидаемый результат
Начало
Обработали: Поставщик недоступен
Завершили попытку
Здесь catch и finally относятся к одному try: сначала обработка, затем finally. Вложенный пример ниже намеренно другой: внутренний finally закрывает поток до внешнего catch.
finally выполняет завершающий шаг в обычном управляемом ходе и при успехе, и при исключении. Он не откатывает уже отправленный платёж и не гарантирован при аварийном завершении процесса. Предупреждение функции не обязано быть исключением: изучайте её контракт. Ниже отделены ошибки данных, бизнес-отказ и технический сбой; приложение реагирует на них по-разному.
Ошибка проходит через вызовы: кто способен принять решение
Предпосылка — вызов функции и return. Функция чтения может не знать, что показывать покупателю. Она сообщает о невозможности получить результат, а вызывающий код решает, как закончить запрос.
- Страница вызывает подготовку карточки
- Подготовка вызывает получение остатка
- Получение остатка бросает исключение
- Внутренние вызовы прекращаются
- Catch на странице выбирает сообщение
<?php
function readStock()
{
echo "Запросили остаток\n";
throw new RuntimeException('Сервис склада недоступен');
}
function productCard()
{
$stock = readStock();
return 'В наличии: ' . $stock;
}
try {
echo productCard();
} catch (RuntimeException $error) {
echo "Остаток пока неизвестен\n";
}
echo "Страница подготовлена\n";
Ожидаемый результат
Запросили остаток
Остаток пока неизвестен
Страница подготовлена
ReadStock не вернула число, productCard не собрала карточку. Подстановка 0 притворилась бы достоверным отсутствием товара; здесь показано отдельное состояние «неизвестно». В примере сеть не вызывается: сбой намеренно эмулирован, чтобы воспроизвести путь.
Несколько catch: выполняется первый подходящий
- Исключение имеет конкретный класс
- PHP сравнивает catch сверху вниз
- Первое совпадение выполняется
- Остальные соседние catch пропускаются
<?php
try {
throw new InvalidArgumentException('Количество должно быть положительным');
} catch (InvalidArgumentException $error) {
echo "Исправьте количество\n";
} catch (Exception $error) {
echo "Операция не завершена\n";
}
Ожидаемый результат
Исправьте количество
InvalidArgumentException относится к семейству Exception. Если общий catch Exception поставить первым, до специальной обработки количества дело не дойдёт. Связь общего и частного класса объясняет наследование.
<?php
try {
throw new OutOfBoundsException('Тариф не найден');
} catch (InvalidArgumentException | OutOfBoundsException) {
echo "Проверьте параметры доставки\n";
}
Ожидаемый результат
Проверьте параметры доставки
Вертикальная черта перечисляет допустимые классы для одного обработчика. Такой multi-catch есть с PHP 7.1. Переменная объекта опущена, потому что здесь не читаются его детали; это разрешено с PHP 8. В реальном сервисе диагностические подробности обычно сохраняют отдельно.
Повторный throw и новая ошибка внутри catch
<?php
try {
try {
throw new RuntimeException('Поставщик не ответил');
} catch (RuntimeException $error) {
echo "Сбой зарегистрирован\n";
throw $error;
}
} catch (RuntimeException $error) {
echo "Заказ не отправлен\n";
}
Ожидаемый результат
Сбой зарегистрирован
Заказ не отправлен
Повторно передан тот же объект. Внутренний catch не обязан поглотить ошибку; он может добавить действие и передать управление выше. Echo имитирует регистрацию; реальный журнал не выводят покупателю.
<?php
try {
try {
throw new InvalidArgumentException('Неверный запрос');
} catch (InvalidArgumentException $error) {
throw new RuntimeException('Не удалось записать журнал');
} catch (RuntimeException $error) {
echo "Этот обработчик пропущен\n";
}
} catch (RuntimeException $error) {
echo 'Внешний обработчик: ', $error->getMessage(), "\n";
}
Ожидаемый результат
Внешний обработчик: Не удалось записать журнал
Список catch относится к своему try, а не друг к другу. Новая ошибка в обработчике уходит к внешней конструкции. Поэтому авария системы журналирования может затмить первоначальную проблему: диагностический путь тоже должен быть продуман.
Throwable, Exception и Error: что именно ловить
| Семейство | Примеры | Обычное назначение |
|---|---|---|
| Exception | RuntimeException, InvalidArgumentException, исключения расширений | Отказы и нарушения договора, определённые API |
| Error | TypeError, ArgumentCountError, ValueError, DivisionByZeroError | Проблема вызова или выполнения операции |
| Throwable | Общий интерфейс обеих веток | Общая граница аварийного завершения |
<?php
try {
strlen([]);
} catch (Exception $error) {
echo "Ветка Exception\n";
} catch (Throwable $error) {
echo get_class($error), "\n";
}
Ожидаемый результат
TypeError
Мы специально передали массив функции, принимающей строку. Это ошибка программы, которую следует исправить. Общая граница Throwable полезна для корректного завершения запроса или задания и сохранения диагностики; она не делает продолжение бизнес-операции безопасным.
В дереве Error также есть ArithmeticError, AssertionError, CompileError/ParseError, UnhandledMatchError и FiberError. Собственный класс исключения обычно наследует Exception или подходящий класс SPL: напрямую реализовать Throwable в пользовательском классе нельзя. Объекты Throwable нельзя клонировать. Для обёртки исходной причины используйте previous, а не clone.
Finally перед выходом: очистка не должна подменить результат
- Вычислили возвращаемое значение
- Перед выходом выполнили finally
- Вернули подготовленное значение вызывающему коду
<?php
function preparedTotal()
{
$total = 1200;
try {
return $total;
} finally {
$total = 9999;
echo "Завершающий шаг\n";
}
}
echo preparedTotal(), "\n";
Ожидаемый результат
Завершающий шаг
1200
Return подготовил значение 1200. Затем выполнился finally. После него вызывающий код получил именно подготовленное число. Пример со скаляром не означает, что изменение общего объекта в finally не будет заметно: для объектов действуют правила общей идентичности.
<?php
function misleadingResult()
{
try {
throw new RuntimeException('Сохранение не удалось');
} finally {
return 'Успех';
}
}
echo misleadingResult(), "\n";
Ожидаемый результат
Успех
Это намеренно неправильная конструкция для демонстрации. Return в finally подавил исходное исключение. Не возвращайте успешный результат из блока очистки: читатель и вызывающий код потеряют отказ сохранения.
Finally при новых исключениях, exit и приостановке генератора
<?php
try {
try {
throw new RuntimeException('Ошибка отправки');
} finally {
throw new LogicException('Ошибка завершения');
}
} catch (Throwable $error) {
echo $error->getMessage(), "\n";
echo $error->getPrevious()->getMessage(), "\n";
}
Ожидаемый результат
Ошибка завершения
Ошибка отправки
Наружу вышла новая ошибка из finally, предыдущая сохранена как причина. Не делайте в очистке ненужные операции, способные скрыть основной сбой. Если освобождение само может отказать, договор обработки обеих причин должен быть явным.
<?php
try {
echo "Завершаем процесс\n";
exit(0);
} finally {
echo "Этот finally не выполняется\n";
}
Ожидаемый результат
Завершаем процесс
Этот самостоятельный учебный процесс заканчивается кодом 0. Exit внутри try/catch, фатальная остановка и внешнее завершение процесса не дают универсальной гарантии очистки. Не используйте finally как единственную защиту от повторного платежа или потери задания.
<?php
function reportRows()
{
try {
yield 'Строка A';
yield 'Строка B';
} finally {
echo "Генератор завершён\n";
}
}
$rows = reportRows();
echo $rows->current(), "\n";
echo "Пауза вызывающего кода\n";
$rows->next();
echo $rows->current(), "\n";
$rows->next();
Ожидаемый результат
Строка A
Пауза вызывающего кода
Строка B
Генератор завершён
Этот поздний опыт требует понимания yield и генераторов. Finally ещё не выполнен после первого yield: генератор приостановлен. При нормальном завершении он выполняется. При принудительном закрытии нельзя выдавать новые значения из finally; PHP сообщает Error.
Есть отдельный исторически меняющийся случай: exit в foreach по временному генератору может вызвать finally самого уничтожаемого генератора, хотя внешний finally пропускается. Это не общий способ гарантировать очистку. Подробное правило и версии приведены в официальной странице finally; устойчивый код явно управляет жизнью ресурсов и не строит договор на моменте уничтожения временного объекта.
Пользовательский обработчик диагностик: когда warning становится исключением
Предпосылки: анонимная функция как callback и битовая маска. Иногда библиотека сообщает warning, а приложению удобнее один путь обработки. Устанавливаем адаптер на ограниченный участок и обязательно возвращаем предыдущий обработчик.
- PHP сформировал warning
- Callback проверил маску error_reporting
- Создал ErrorException с исходным уровнем и местом
- Обычный catch обработал выбранную диагностику
<?php
set_error_handler(function ($severity, $message, $file, $line) {
if (!(error_reporting() & $severity)) {
return false;
}
throw new ErrorException($message, 0, $severity, $file, $line);
}, E_USER_WARNING);
try {
trigger_error('Формат старого экспорта больше не поддерживается', E_USER_WARNING);
} catch (ErrorException $error) {
echo $error->getMessage(), "\n";
echo $error->getSeverity(), "\n";
} finally {
restore_error_handler();
}
Ожидаемый результат
Формат старого экспорта больше не поддерживается
512
Set_error_handler получает callback и выбранные уровни. False делегирует стандартному механизму. Здесь E_USER_WARNING имеет значение 512. Передача severity/file/line сохраняет происхождение диагностики. Не устанавливайте глобальный перехват всех предупреждений в чужом приложении без согласованной политики.
Не все диагностики доступны обработчику: в частности E_ERROR, E_PARSE, E_CORE_ERROR/E_CORE_WARNING, E_COMPILE_ERROR/E_COMPILE_WARNING и большинство E_STRICT в файле регистрации не обрабатываются этим механизмом. Регистрация во время выполнения также не исправляет синтаксическую ошибку, из-за которой файл вообще не начал исполняться.
Что видит разработчик, что получает посетитель и что остаётся в журнале
| Настройка | Роль | Практическое решение |
|---|---|---|
| error_reporting | Какие уровни учитывать | E_ALL в разработке; не прятать проблему ради чистого экрана |
| display_errors | Попадёт ли диагностика в вывод | На публичном сайте выключить |
| log_errors и error_log | Запись в закрытый журнал | Настроить путь, доступ, ротацию и наблюдение |
| fatal_error_backtraces, PHP 8.5+ | Trace у фатальной диагностики | Не показывать внутренние пути посетителю |
Настройте среду в php.ini/FPM/web-сервере заранее: ini_set в неисправном файле не выполнится при ошибке разбора этого файла. Ошибки в выводе способны испортить JSON-ответ и раскрыть внутренние данные. Посетителю нужен понятный исход, разработчику — техническая причина с идентификатором события, без паролей, токенов и полного тела запроса.
Set_exception_handler задаёт последний обработчик непойманного Throwable. После его завершения программа не возвращается к оборвавшейся строке. Для HTTP отдельно задают статус ответа; для CLI — ненулевой exit code. Ошибка в самом глобальном обработчике не будет повторно обрабатываться им же: аварийный путь должен быть коротким и надёжным. Настройка рабочей среды и проверка отказов продолжают эту тему.
Собственное исключение и previous: переводим техническую причину в смысл задачи
Предпосылки: конструктор и наследование. Внешнему слою полезно знать «каталог не загружен», но диагностика должна сохранить, почему это произошло.
<?php
class CatalogUnavailable extends RuntimeException
{
public function __construct(string $message, ?Throwable $previous = null)
{
parent::__construct($message, 0, $previous);
}
}
try {
try {
throw new RuntimeException('Тайм-аут тестового источника');
} catch (RuntimeException $cause) {
throw new CatalogUnavailable('Каталог пока недоступен', $cause);
}
} catch (CatalogUnavailable $error) {
echo $error->getMessage(), "\n";
echo 'Причина для разработчика: ', $error->getPrevious()->getMessage(), "\n";
}
Ожидаемый результат
Каталог пока недоступен
Причина для разработчика: Тайм-аут тестового источника
Родительский конструктор заполнил message, code и previous. В новом классе нет настоящего HTTP-запроса; показана граница между техническим и прикладным смыслом. В браузере строку с технической причиной не выводят пользователю.
GetMessage/getCode дают сообщение и код; getFile/getLine — место возникновения; getTrace/getTraceAsString — цепочку вызовов; getPrevious — вложенную причину или null. Трассировка может содержать чувствительные аргументы. __toString можно переопределить в своём исключении, но это не заменяет безопасное структурированное журналирование.
Практика. Что делать, если поиск товара корректно вернул пустой список? Это не обязательно исключение. А если запрос оборвался, пустой список выдаст ложный результат. Зафиксируйте отдельно успешное отсутствие и техническую невозможность узнать результат; повторите оба случая в тесте.
Не все неудачи одинаковы
Сначала закрываем поток, затем объясняем ошибку
В примере ниже разбор JSON расположен во внутреннем try/finally, а сообщение об ошибке — во внешнем catch.
- Внутренний try: разобрать JSON
Некорректный JSON вызывает исключение. Обычный путь обработки прерывается.
- Внутренний finally: закрыть поток
До выхода из внутреннего блока освобождаем ресурс. Вывод: «Поток закрыт».
- Внешний catch: объяснить отказ
Исключение приходит обработчику снаружи. Вывод: «Разбор не удался».
Порядок зависит от вложенности: здесь finally находится внутри, а catch снаружи. В одном try/catch/finally подходящий catch выполняется перед его finally. Успешный путь пропускает catch; аварийное завершение процесса не гарантирует выполнение finally.
Пользователь не указал адрес, поставщик вернул битый JSON, база недоступна и программист передал массив в strlen — разные проблемы. Первая часть обычного контракта формы, вторая ошибка интеграции, третья инфраструктура, четвертая нарушение кода. Если ловить все и возвращать пустой массив, магазин будет показывать «заказов нет» вместо сообщения о сбое. Исключение передает контроль к ближайшему подходящему catch; код после throw в текущем пути не выполняется.
- Сервис обнаруживает неверное состояние
- throw создает прерывание
- Стек разворачивается до подходящего catch
- finally освобождает ресурс
- Граница HTTP/CLI выбирает ответ и код завершения
Throwable объединяет Exception и Error. TypeError и ValueError являются Error, поэтому catch(Exception) их не поймает. Обычные предупреждения старых функций не начинают автоматически бросать исключения: file_get_contents может выдать warning и вернуть false. Знание контракта результата остается важным. set_error_handler может переводить выбранные предупреждения в ErrorException, но общий обработчик должен учитывать error_reporting и возвращаться к предыдущей настройке.
Отделить неудачу бизнес-правила
<?php
declare(strict_types=1);
class InsufficientStock extends RuntimeException {}
function reserve(int $available, int $requested): int
{
if ($requested < 1) {
throw new InvalidArgumentException('Количество должно быть положительным');
}
if ($requested > $available) {
throw new InsufficientStock('Недостаточно остатка');
}
return $available - $requested;
}
try {
echo reserve(2, 3), "\n";
} catch (InsufficientStock $exception) {
echo "Уменьшите количество: в наличии2\n";
}
Ожидаемый результат
Уменьшите количество: в наличии2
Контроллер может превратить ошибку в HTTP 409 или 422 по принятому контракту. Такой расчет сам по себе не резервирует склад конкурентно безопасно; реальная операция требует транзакции/условного обновления. Тип исключения позволяет не перепутать нехватку остатка с недоступностью базы.
finally освобождает ресурс независимо от результата
<?php
declare(strict_types=1);
$handle = fopen('php://temp', 'w+');
if ($handle === false) {
throw new RuntimeException('Не открыть временный поток');
}
try {
try {
fwrite($handle, '{broken');
rewind($handle);
$body = stream_get_contents($handle);
if ($body === false) {
throw new RuntimeException('Не прочитать поток');
}
json_decode($body, true, 16, JSON_THROW_ON_ERROR);
} finally {
fclose($handle);
echo "Поток закрыт\n";
}
} catch (JsonException $exception) {
echo "Разбор не удался\n";
}
Ожидаемый результат
Поток закрыт
Разбор не удался
finally работает при обычном раскручивании стека, в том числе return/throw. Не полагайтесь на него при аварийном завершении процесса. Не возвращайте значение из finally: оно может перекрыть исходный return или исключение и скрыть проблему.
Ловим там, где можно принять решение
Маленькая функция не обязана оборачивать все в try/catch. Она проверяет свой контракт и позволяет ошибке подняться к слою, который умеет показать форму, откатить транзакцию или перенести задачу очереди. При оборачивании исключения передавайте previous, чтобы сохранить цепочку причин. Пользовательское сообщение отделяйте от технического журнала. Не выводите полный текст исключения базы или HTTP-клиента посетителю: там могут быть пути и фрагменты данных.
Повторять можно не каждую ошибку. Неверный пароль или битая схема JSON не станут верными после sleep. Тайм-аут после отправки платежа неоднозначен: сервер мог уже исполнить операцию. Повтор с тем же ключом идемпотентности и проверкой статуса безопаснее слепого повтора. catch(Throwable) полезен на продуманной верхней границе для логирования/ответа, но не как способ продолжить работу с непонятным состоянием.
Попробуйте сами. В функции чтения конфигурации catch(Throwable){return [];} — что увидит приложение и как улучшить?
Разбор решения
Оно не отличит пустую конфигурацию от битого JSON, ошибки пути или ошибки программиста. Проверьте false чтения, декодируйте с JSON_THROW_ON_ERROR, добавьте контекст без секретов и сохраните previous. На старте приложения отсутствие обязательной конфигурации обычно останавливает запуск.
Источники и обсуждения
- PHP Manual: class.throwable
- PHP Manual: class.error
- PHP Manual: class.exception
- set_error_handler() — официальный справочник
- PHP Manual: control-structures.try-catch
- PHP Manual: control-structures.finally
- PHP Manual: function.return
- PHP Manual: language.error-handling
- PHP Manual: language.error-handling.diagnostic
- PHP Manual: language.error-handling.throwable
- PHP Manual: control-structures.throw
- PHP Manual: class.errorexception
- PHP Manual: function.set-exception-handler
- PHP Manual: function.error-reporting
- PHP Manual: function.trigger-error
- PHP Manual: errorfunc.configuration