Ошибки и исключения (try/catch)

Ошибки случаются. Главное - чтобы это происходило у тебя в логах, а не у пользователя в 3 ночи.

Ошибки vs исключения

В PHP есть два механизма обработки проблем:

Ошибки (errors) - генерирует сам PHP: Warning, Notice, Fatal Error. Их нельзя «поймать» обычным try/catch (до PHP 7).

Исключения (exceptions) - объекты, которые ты бросаешь (throw) и ловишь (catch). Это управляемый механизм.

<?php
// Ошибка - PHP сам сообщает:
echo $undefinedVariable; // Warning: Undefined variable

// Исключение - ты бросаешь сам:
throw new RuntimeException('Что-то не так');

Уровни ошибок PHP

<?php
// Notice - мелочь, но лучше исправить
echo $x; // Notice: Undefined variable

// Warning - серьёзнее, но скрипт продолжает работу
include 'файл-которого-нет.php'; // Warning

// Fatal Error - скрипт останавливается
вызов_несуществующей_функции(); // Fatal error
В разработке включай все ошибки: `error_reporting(E_ALL)`. В продакшне логируй, но не показывай: `display_errors = Off`, `log_errors = On`. Подробнее про структурированные логи - в [уроке про логирование](./23-logging.md).

try/catch

<?php
try {
    $result = riskyOperation();
    echo "Успех: $result";
} catch (RuntimeException $e) {
    echo 'Ошибка: ' . $e->getMessage();
}

Можно ловить несколько типов:

<?php
try {
    $data = json_decode($input, true, 512, JSON_THROW_ON_ERROR);
    $pdo->prepare('...')->execute($data);
} catch (\JsonException $e) {
    echo 'Невалидный JSON: ' . $e->getMessage();
} catch (\PDOException $e) {
    error_log('DB error: ' . $e->getMessage());
    echo 'Ошибка базы данных';
}

В PHP 8+ можно ловить несколько типов в одном блоке:

<?php
try {
    // ...
} catch (\JsonException | \InvalidArgumentException $e) {
    echo 'Ошибка ввода: ' . $e->getMessage();
}

finally

Блок finally выполняется всегда - и при успехе, и при ошибке:

<?php
$file = fopen('data.txt', 'r');

try {
    $content = fread($file, filesize('data.txt'));
    // обработка...
} catch (\Throwable $e) {
    error_log($e->getMessage());
} finally {
    fclose($file); // закроется в любом случае
}

Иерархия исключений

Throwable (интерфейс)
├── Error (ошибки PHP - TypeError, DivisionByZeroError, etc.)
└── Exception
    ├── RuntimeException
    ├── InvalidArgumentException
    ├── LogicException
    ├── JsonException
    └── PDOException
`catch (Exception $e)` НЕ ловит `Error`. Если хочешь поймать всё - используй `catch (\Throwable $e)`. Но обычно `Error` - это баг в коде, который нужно исправлять, а не ловить.

Свои исключения

<?php
declare(strict_types=1);

final class ValidationException extends \RuntimeException {
    public function __construct(private readonly array $errors) {
        parent::__construct('Ошибка валидации');
    }

    public function getErrors(): array {
        return $this->errors;
    }
}

function validateAge(string $input): int {
    if (!ctype_digit($input)) {
        throw new ValidationException(['age' => 'Должно быть числом']);
    }
    $age = (int)$input;
    if ($age < 1 || $age > 150) {
        throw new ValidationException(['age' => 'Возраст от 1 до 150']);
    }
    return $age;
}

Использование:

<?php
try {
    $age = validateAge($_POST['age'] ?? '');
    echo "Возраст: $age";
} catch (ValidationException $e) {
    foreach ($e->getErrors() as $field => $message) {
        echo "$field: $message\n";
    }
}

set_exception_handler - глобальный обработчик

Если исключение не поймано нигде - оно попадёт сюда:

<?php
set_exception_handler(function (\Throwable $e) {
    error_log("Unhandled: {$e->getMessage()} in {$e->getFile()}:{$e->getLine()}");

    http_response_code(500);
    header('Content-Type: application/json');
    echo json_encode(['error' => 'Internal Server Error']);
});

Это последняя линия обороны - ставь в точке входа (index.php), как в уроке про роутер.

Практический паттерн: обработка в API

<?php
declare(strict_types=1);

function handleRequest(): void {
    try {
        $method = $_SERVER['REQUEST_METHOD'];
        $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

        // роутинг и обработка...
        $result = processRoute($method, $path);

        header('Content-Type: application/json');
        echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);

    } catch (ValidationException $e) {
        http_response_code(400);
        echo json_encode(['error' => $e->getMessage(), 'details' => $e->getErrors()]);

    } catch (\JsonException $e) {
        http_response_code(400);
        echo json_encode(['error' => 'Invalid JSON']);

    } catch (\PDOException $e) {
        error_log('DB: ' . $e->getMessage());
        http_response_code(500);
        echo json_encode(['error' => 'Database error']);

    } catch (\Throwable $e) {
        error_log('Fatal: ' . $e->getMessage());
        http_response_code(500);
        echo json_encode(['error' => 'Internal Server Error']);
    }
}
`$e->getMessage()`, `$e->getTraceAsString()` могут содержать пути к файлам, SQL-запросы, пароли. В продакшне - только в логи (см. [урок про безопасность](./20-security.md)).

Типичные ошибки

  • catch (\Throwable $e) где попало. Ты ловишь TypeError, DivisionByZeroError и прочие баги PHP, маскируя их под бизнес-ошибки. Лови конкретные типы (ValidationException, \PDOException), а \Throwable - только в точке входа.
  • Пустой catch - проглатывание исключений. catch (\Exception $e) {} молча теряет ошибку, ты узнаешь о баге через неделю от пользователей. Минимум - error_log($e); лучше - пробросить дальше через throw.
  • Возврат false/null вместо throw. Функция findUser() возвращает false при ошибке БД и null при «не найдено» - вызывающий код путается. Бросай исключение на нарушение инварианта, возвращай null только на ожидаемое отсутствие.
  • \RuntimeException для всего подряд. Без доменных типов нельзя селективно ловить - catch (\RuntimeException) поймает и валидацию, и сбой БД. Заводи ValidationException, NotFoundException, PaymentDeclinedException.
  • Нет set_exception_handler в точке входа. Непойманное исключение вываливается стектрейсом в браузер с путями и SQL. Поставь обработчик в index.php: лог + http_response_code(500) + generic JSON.

Best practices

  • Бросай исключение при нарушении инварианта, возвращай значение при ожидаемом ветвлении (null для «не найдено», Result для «не удалось»).

  • Лови самый конкретный тип, который реально умеешь обработать; \Throwable - только на верхнем уровне.

  • Заводи доменные исключения (ValidationException extends \RuntimeException) - это часть контракта слоя.

  • Никогда не пиши пустой catch: либо логируй и пробрасывай, либо конвертируй в другой тип через throw new X(..., previous: $e).

  • В Symfony не делай свой глобальный try/catch - подпишись на kernel.exception через ExceptionListener или маппинг через #[AsEventListener], фреймворк сам конвертирует исключение в HTTP-ответ.

  • Python - Исключения: try/except/else/finally и кастомные exceptions - тот же механизм в Python: иерархия BaseException, ExceptionGroup (3.11+)

Мини-задание

  • Напиши функцию parseAge(string $s): int - если не число или вне диапазона, бросай ValidationException
  • Обработай ValidationException и \Throwable в разных catch-блоках
  • Создай свой NotFoundException extends \RuntimeException и используй его
  • Добавь finally для закрытия ресурса (файл или соединение)
  • Настрой set_exception_handler как глобальный обработчик

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.