JSON и API в PHP: типичные ошибки и как их избежать
json_decode вернул null, а вы этого не проверили — и клиентский код упал на пустом ответе. Разбираем ошибки при работе с JSON и HTTP-API в PHP 8.x: от молчаливых null до таймаутов, которые вешают весь запрос.

Скрипт получает данные от внешнего API, парсит JSON, читает поле — и падает с Attempt to read property on null. В логах пусто: json_decode вернул null, но никто это не проверил. Классика PHP-интеграций. Ниже — конкретные грабли при работе с JSON и HTTP-API в PHP 8.1–8.3 и что писать вместо привычного, но опасного кода.
Ошибка 1. json_decode без проверки результата
Самое частое. json_decode при невалидном входе возвращает null и по умолчанию не бросает исключений. А null — валидное значение JSON (строка "null" декодируется именно в него). Значит, по одному только === null вы не отличите ошибку парсинга от корректного null в ответе.
Так делают почти все — и почти все получают тихие баги:
// плохо: null трактуется как ошибка, хотя это может быть валидный ответ
$data = json_decode($response);
if ($data === null) {
// сюда попадёт и "null", и битый JSON, и пустая строка
}
С PHP 7.3 есть флаг JSON_THROW_ON_ERROR — он превращает ошибку парсинга в исключение JsonException. Это единственный надёжный способ отличить сбой от валидного null:
// PHP 8.x
try {
// true во втором аргументе — вернуть ассоциативный массив, а не объект
$data = json_decode($response, true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
// сюда попадает только реально битый JSON
error_log('Не удалось разобрать ответ API: ' . $e->getMessage());
// дальше — фолбэк, а не работа с null
return null;
}
Третий аргумент — глубина вложенности (по умолчанию 512). Если API отдаёт глубоко вложенные структуры, при превышении лимита тоже прилетит исключение — это тоже надо ловить, а не молча терять данные.
Правило простое: любой вызов json_decode на внешних данных идёт с JSON_THROW_ON_ERROR и в try/catch. Внешним данным доверять нельзя по определению — их формат вам не подконтролен.
Ошибка 2. Молчаливая потеря данных в json_encode
Обратная сторона. json_encode без флагов на невалидной строке (например, с битой UTF-8-последовательностью) вернёт false, и вы отправите клиенту false вместо тела ответа. Клиент получит HTTP 200 с телом, которое не парсится.
Частый источник битого UTF-8 — данные из базы в кодировке cp1251 или результат substr, разрезавший многобайтовый символ пополам.
$json = json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
// JSON_UNESCAPED_UNICODE — чтобы кириллица не превращалась в \u0430\u0431
// без флага размер ответа с русским текстом вырастает в разы
Что стоит добавлять к json_encode для API:
- JSON_THROW_ON_ERROR — исключение вместо тихого
false; - JSON_UNESCAPED_UNICODE — кириллица остаётся читаемой и не раздувает ответ;
- JSON_UNESCAPED_SLASHES — убирает экранирование слэшей в URL внутри значений;
- JSON_PRETTY_PRINT — только для отладки и человекочитаемых конфигов, не для боевого API (лишний трафик).
Ошибка 3. HTTP-запрос без таймаута
Второй по частоте инцидент после null. Вы дёргаете внешний API через cURL или file_get_contents, у сервера-партнёра проблемы, и ваш PHP-процесс висит до упора в max_execution_time (а если запрос в CLI — вообще без ограничения). Под нагрузкой это выедает воркеры PHP-FPM: один медленный внешний сервис кладёт весь ваш сайт.
Обязательные таймауты для cURL:
$ch = curl_init('https://api.example.com/v1/orders');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true, // вернуть тело, а не печатать в stdout
CURLOPT_CONNECTTIMEOUT => 5, // сколько ждём установку соединения
CURLOPT_TIMEOUT => 15, // общий лимит на весь запрос
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$body = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$curlErr = curl_error($ch);
curl_close($ch);
if ($body === false) {
// сетевой сбой или таймаут — это НЕ то же самое, что ответ 500
error_log('cURL error: ' . $curlErr);
// возвращаем ошибку, а не парсим false как JSON
}
Различайте два разных провала: транспорт (соединение не установилось, таймаут — curl_exec вернул false) и уровень приложения (сервер ответил, но с кодом 4xx/5xx). Это разные ветки обработки: транспортную ошибку можно повторить, а 400 повторять бессмысленно.
Ошибка 4. Игнорирование HTTP-кода ответа
Тело ответа парсят сразу, не глядя на статус. Но при 401, 429 или 500 в теле лежит не ожидаемая структура, а сообщение об ошибке — и ваш парсер работает с чужим форматом. В лучшем случае поле окажется null, в худшем — вы примете строку ошибки за валидные данные.
| Код | Что значит | Что делать |
|---|---|---|
| 2xx | Успех | Парсить тело |
| 400, 422 | Ошибка в запросе | Не повторять, логировать, чинить запрос |
| 401, 403 | Проблема с авторизацией | Обновить токен, не повторять с тем же |
| 429 | Превышен лимит запросов | Пауза по заголовку Retry-After, потом повтор |
| 5xx | Сбой на стороне API | Повтор с экспоненциальной задержкой |
Проверяйте код до парсинга: если он не в диапазоне 2xx, ветка обработки ошибки, а не json_decode.
Ошибка 5. Обращение к полям, которых может не быть
API поменял формат, отдал ошибку вместо данных или просто не вернул необязательное поле — и $data['user']['email'] роняет предупреждение Undefined array key, а на объектах — фатальную ошибку чтения свойства null.
С PHP 7.0 есть оператор ??, с PHP 8.0 — nullsafe-оператор ?->. Используйте их для необязательных полей:
// ассоциативный массив (json_decode с true)
$email = $data['user']['email'] ?? null;
$page = $data['meta']['page'] ?? 1;
// объект (json_decode без второго аргумента)
$email = $data?->user?->email ?? null;
Но ?? — не замена валидации. Если поле обязательно по контракту API, его отсутствие — это сигнал, что что-то сломалось, а не повод молча подставить дефолт. Обязательные поля проверяйте явно и падайте с понятной ошибкой, необязательные — закрывайте через ??.
Ошибка 6. Не тот Content-Type при отправке JSON
Отправляете JSON в POST, но не ставите заголовок — и принимающая сторона парсит тело как application/x-www-form-urlencoded, получая пустой массив. Симптом: «я же отправил данные, а сервер говорит, что их нет».
$ch = curl_init('https://api.example.com/v1/orders');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($order, JSON_THROW_ON_ERROR),
CURLOPT_HTTPHEADER => [
'Content-Type: application/json', // без этой строки тело прочитают неверно
'Accept: application/json',
],
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 15,
]);
Симметрично на приёме: $_POST для JSON-тела пуст. Читать надо сырой поток входа и декодировать вручную — json_decode(file_get_contents('php://input'), true, 512, JSON_THROW_ON_ERROR).
Если используете Guzzle
Ручной cURL многословен, и большинство проектов берёт HTTP-клиент Guzzle. Он закрывает часть граблей из коробки, но не все. По умолчанию Guzzle бросает исключение на 4xx и 5xx — это удобно, но означает, что забытый try/catch обрушит запрос. Таймаут в Guzzle по умолчанию не ограничен, его нужно задавать явно опциями timeout и connect_timeout. Точный набор опций и их поведение в вашей версии сверяйте с документацией Guzzle — API опций между мажорными версиями менялся.
Что проверить в своём коде сегодня
- Найдите все
json_decodeна внешних данных и добавьтеJSON_THROW_ON_ERRORс обработкойJsonException. - Проверьте, что у каждого исходящего HTTP-запроса стоят
connect_timeoutиtimeout. Один запрос без таймаута — потенциальный отказ всего сервиса. - Убедитесь, что HTTP-код ответа проверяется до парсинга тела.
- Разделите обработку транспортных ошибок и ошибок уровня приложения — их нельзя лечить одинаково.
- Настройте логирование тела и кода ответа при сбоях: без этого разбор инцидента превращается в гадание.
Следующий шаг для боевой интеграции — вынести retry с экспоненциальной задержкой и уважением заголовка Retry-After в отдельный слой, чтобы каждый вызов API не обрастал одинаковой обвязкой. Это уже отдельная тема — здесь мы разобрали только базовые грабли парсинга и транспорта.
Проектная работа: От проблемы до защиты: продукт, этапы, вопросы комиссии
Спросить за 15 ₽Источники: PHP Manual: json_decode, PHP Manual: JSON constants (флаги)
Частые вопросы
Чем json_decode с true отличается от без него?
У меня PHP 7.2, а JSON_THROW_ON_ERROR не работает — что делать?
Почему json_encode превращает русский текст в \u0430\u0431?
Ставлю таймаут 15 секунд, но запрос всё равно висит дольше — почему?
Стоит ли писать свой HTTP-клиент на cURL или брать Guzzle?
json_decode вернул null, хотя ответ выглядит корректным JSON — в чём дело?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.