Две системы ошибок в одном языке

В PHP есть две близкие, но разные системы: диагностические ошибки движка и исключения как объекты. Первая система работает через уровни вроде E_WARNING, E_NOTICE, E_DEPRECATED, E_ERROR и настройки error_reporting. Вторая работает через throw, try/catch/finally и объекты, реализующие Throwable.

<?php

declare(strict_types=1);

$value = json_decode('{broken json}', true);

if (json_last_error() !== JSON_ERROR_NONE) {
    throw new RuntimeException(json_last_error_msg());
}

Здесь ошибка формата данных превращается в исключение приложения. Это уже не просто warning в выводе PHP, а явный сигнал: текущая операция не может продолжаться нормальным путём. Такая граница особенно важна рядом с JSON, сериализация и форматы данных, PDO и подключение к базе и Очереди, фоновые задачи и воркеры, где сбой нужно обработать предсказуемо.

flowchart TD A[Что-то пошло не так] --> B{Это брошенный объект?} B -->|Да| C[Throwable] C --> D[Exception: прикладные и библиотечные исключения] C --> E[Error: ошибки языка и рантайма] B -->|Нет| F[PHP diagnostic error level] F --> G[E_WARNING / E_NOTICE / E_DEPRECATED] F --> H[E_ERROR / E_PARSE / compile/core errors] G --> I[Можно обработать через set_error_handler] I --> J[При необходимости бросить ErrorException] D --> K[try / catch / finally] E --> K J --> K
flowchart TD
    A[Что-то пошло не так] --> B{Это брошенный объект?}
    B -->|Да| C[Throwable]
    C --> D[Exception: прикладные и библиотечные исключения]
    C --> E[Error: ошибки языка и рантайма]
    B -->|Нет| F[PHP diagnostic error level]
    F --> G[E_WARNING / E_NOTICE / E_DEPRECATED]
    F --> H[E_ERROR / E_PARSE / compile/core errors]
    G --> I[Можно обработать через set_error_handler]
    I --> J[При необходимости бросить ErrorException]
    D --> K[try / catch / finally]
    E --> K
    J --> K
Связь между уровнями ошибок PHP, Throwable, Exception, Error и ErrorException.

Error levels: warning — ещё не exception

Уровни ошибок PHP — это битовая маска. E_ALL включает стандартный набор диагностик, а через error_reporting(E_ALL) или php.ini можно указать, какие уровни учитывать. Типичные уровни:

  • E_WARNING — runtime warning: выполнение обычно продолжается;
  • E_NOTICE — подозрительная ситуация, которая не всегда является багом;
  • E_DEPRECATED и E_USER_DEPRECATED — код работает, но API считается устаревшим;
  • E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR — фатальные ошибки, после которых обычное выполнение не продолжается.

Важно: warning сам по себе не становится Exception. Например, неудачный include, обращение к несуществующему файлу или сломанный unserialize() могут дать warning, который catch (Throwable $e) не поймает, если вы специально не настроили обработчик ошибок.

<?php

try {
    unserialize('broken data'); // warning, а не Throwable
    echo 'После warning код может продолжиться';
} catch (Throwable $e) {
    echo 'Этот catch не обязан сработать для warning';
}

Оператор @ подавляет диагностическое сообщение у выражения, но это почти всегда плохой способ обработки ошибок. Он прячет симптом, не добавляя нормального решения. В прикладном коде лучше явно проверить результат функции или превратить ошибку в исключение там, где это оправдано.

Throwable, Exception и Error

Throwable — базовый интерфейс для всего, что можно бросить через throw и поймать через catch. В PHP его реализуют две большие ветки: Exception и Error.

Exception — базовый класс для пользовательских и многих библиотечных исключений. Обычно от него наследуют доменные исключения: InvalidOrderState, PaymentDeclined, CannotCreateInvoice.

Error — базовый класс для внутренних ошибок PHP: например, TypeError, ValueError, ParseError, UnhandledMatchError, FiberError. Это не «исключения бизнес-логики», а сигнал, что код нарушил контракт языка или рантайма.

<?php

declare(strict_types=1);

function percent(int $value): int
{
    if ($value < 0 || $value > 100) {
        throw new InvalidArgumentException('Процент должен быть от 0 до 100');
    }

    return $value;
}

try {
    percent(150);
} catch (InvalidArgumentException $e) {
    echo 'Ошибка входных данных: ' . $e->getMessage();
} catch (Throwable $e) {
    echo 'Неожиданный сбой: ' . $e::class;
}

Последний catch (Throwable $e) полезен на внешней границе приложения: HTTP entrypoint, CLI-команда, воркер очереди. Внутри доменной логики он часто слишком широк. Если поймать всё подряд и тихо продолжить, можно скрыть TypeError, ошибку конфигурации или баг после миграции версии PHP. Это связано с Типы и strict_types: типовой контракт должен падать заметно, а не превращаться в «пустой результат».

try, catch, finally

try оборачивает участок, где может быть брошен Throwable. catch выбирает обработчик по классу. PHP берёт первый подходящий catch, поэтому более конкретные классы ставят выше более общих.

<?php

try {
    $user = loadUser($id);
    sendWelcomeEmail($user);
} catch (UserNotFound $e) {
    http_response_code(404);
} catch (MailTransportFailed $e) {
    // Пользователь создан, но письмо не ушло: логируем и отдаём мягкий ответ.
    error_log($e->getMessage());
} catch (Throwable $e) {
    error_log((string) $e);
    http_response_code(500);
}

finally выполняется после try и catch независимо от результата. Его используют для освобождения ресурсов: закрыть lock, вернуть временную настройку, закончить span трассировки. Не стоит делать return из finally: он может затереть исходный результат или исключение, и такой код трудно читать.

<?php

$lock = acquireLock('billing');

try {
    rebuildInvoices();
} finally {
    $lock->release();
}

Пользовательские исключения

Custom exception — это не место для красивого текста ошибки. Главное в ней — тип. По типу вызывающий код понимает, что именно произошло и можно ли восстановиться.

<?php

final class NotEnoughBalance extends RuntimeException
{
    public function __construct(
        public readonly int $accountId,
        public readonly int $requested,
        public readonly int $available,
    ) {
        parent::__construct('Недостаточно средств на счёте');
    }
}

function withdraw(int $accountId, int $amount, int $balance): void
{
    if ($amount > $balance) {
        throw new NotEnoughBalance($accountId, $amount, $balance);
    }
}

Такое исключение можно логировать, показать пользователю понятный ответ или обработать в API. В отличие от голого RuntimeException('oops'), оно несёт структурированный контекст. Но не нужно создавать отдельный класс на каждый мелкий if: если вызывающий код не будет различать эти случаи, достаточно стандартного InvalidArgumentException, RuntimeException или LogicException.

Runtime errors и доменные исключения

Практическая граница такая: Error обычно означает баг или несовместимость кода; доменное Exception означает ожидаемый сбой бизнес-операции.

TypeError при вызове функции — баг на границе типов. ValueError от built-in функции — некорректный аргумент. PDOException при потере соединения — инфраструктурный сбой. NotEnoughBalance — нормальный отказ операции по правилам предметной области.

Эта разница влияет на обработку. Доменное исключение можно превратить в HTTP 422 или понятное сообщение. Инфраструктурный сбой обычно логируют и возвращают 500/503. Ошибку программиста лучше не маскировать: она должна попасть в логи, мониторинг и тесты.

В Транзакции и режимы ошибок PDO эта тема становится особенно конкретной: исключение внутри транзакции обычно должно привести к rollback. В Очереди, фоновые задачи и воркеры оно определяет, будет ли задача повторена, отправлена в dead-letter queue или помечена как окончательно проваленная.

set_error_handler и ErrorException

Если проект хочет единый стиль обработки, warning можно превратить в ErrorException через set_error_handler(). Это делают аккуратно: не все уровни стоит превращать в исключения, особенно deprecation-уведомления в больших легаси-проектах.

<?php

set_error_handler(
    function (int $errno, string $errstr, string $errfile, int $errline): bool {
        if (!(error_reporting() & $errno)) {
            return false;
        }

        if ($errno === E_DEPRECATED || $errno === E_USER_DEPRECATED) {
            return false;
        }

        throw new ErrorException($errstr, 0, $errno, $errfile, $errline);
    }
);

Есть ограничение: пользовательский error handler не ловит все фатальные ошибки, parse errors и ошибки, случившиеся до регистрации обработчика. Поэтому set_error_handler() — не магическая сетка безопасности, а мост между старой системой diagnostics и объектной системой исключений.

Логи, вывод и production

В development полезно видеть подробный stack trace. В production подробности нельзя отдавать пользователю: пути файлов, SQL, env-настройки и stack trace раскрывают внутреннее устройство приложения. Типичный production-подход: error_reporting = E_ALL, display_errors = Off, log_errors = On, а наружу — нейтральный ответ.

<?php

try {
    handleRequest();
} catch (Throwable $e) {
    error_log((string) $e);

    http_response_code(500);
    echo 'Internal Server Error';
}

Фреймворки вроде Laravel и Symfony дают более удобный слой: разные handlers для dev/prod, форматирование ошибок, интеграцию с логами и мониторингом. Но базовая модель остаётся той же: не показывать внутренности наружу, не глотать исключения молча, сохранять контекст для расследования.

Типичные ловушки

Первая ловушка — считать, что catch (Exception $e) поймает всё. Он не поймает Error; для внешней границы нужен Throwable, а для прикладной логики — конкретные классы.

Вторая — ловить Throwable слишком рано. Если функция не знает, как восстановиться, ей лучше дать исключению подняться выше.

Третья — путать warning и exception. try/catch работает с брошенными объектами, а не со всеми сообщениями PHP.

Четвёртая — показывать ошибки пользователю в production. Логи нужны разработчику и ops, а пользователь должен получить безопасный ответ без деталей реализации.

См. также

Источники

  1. PHP Manual: Exceptions
  2. PHP Manual: Predefined error constants
  3. PHP Manual: set_error_handler
  4. PHP Manual: ErrorException
  5. PHP Manual: Error Control Operators
  6. PHP Manual: Throwable
  7. PHP Manual: Error
  8. PHP Manual: Exception
  9. OWASP Cheat Sheet Series: PHP Configuration