Работа с JSON и API в PHP: сравниваем варианты клиентов
cURL, Guzzle, Symfony HttpClient и нативный json_encode с флагами — четыре способа ходить в чужой API из PHP 8.x. У каждого своя цена: контроль, зависимости, поведение при ошибках.

Задача звучит просто: отправить POST в чужой API, получить JSON, разобрать его в массив. На практике вы упираетесь в детали. json_decode молча вернул null вместо ошибки. cURL проглотил таймаут и завис на дефолтных 300 секундах. Сервер прислал Content-Type: application/json, но с невалидным телом, а ваш код упал на попытке обратиться к несуществующему ключу. Ниже — четыре рабочих способа делать HTTP-запросы и разбирать JSON в PHP 8.1+, с ценой каждого.
Разбор JSON: json_decode и его подводные камни
Независимо от того, чем вы делаете запрос, ответ придётся разобрать. И тут первая ловушка: по умолчанию json_decode при невалидном JSON не бросает исключение, а возвращает null. Тот же null вернётся, если сервер честно прислал null. Различить эти два случая через проверку === null нельзя.
С PHP 7.3 есть флаг JSON_THROW_ON_ERROR — используйте его всегда, когда разбираете внешние данные.
<?php
// PHP 8.1+
$raw = '{"id": 42, "name": "widget"}';
try {
// второй аргумент true — ассоциативный массив вместо stdClass
$data = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
// сюда попадём при битом JSON, а не получим тихий null
throw new \RuntimeException('Невалидный ответ API: ' . $e->getMessage());
}
echo $data['name']; // widget
При кодировании работает тот же флаг плюс пара полезных для API:
<?php
$payload = ['name' => 'кабель', 'price' => 199.90];
// JSON_UNESCAPED_UNICODE — кириллица без \u04..
// JSON_UNESCAPED_SLASHES — слэши в URL без экранирования
$json = json_encode(
$payload,
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
);
Отдельная тонкость с большими числами: длинные ID (например, идентификаторы из внешних систем) при декодировании превращаются во float и теряют точность. Флаг JSON_BIGINT_AS_STRING заставляет читать такие числа как строки. Инвалидация и схемы валидации ответа — отдельная тема, здесь разбираем только базовый разбор.
Способ 1 — нативный cURL
cURL встроен в PHP (расширение ext-curl), внешних зависимостей ноль. За это платите многословием и ручной обработкой всего: таймаутов, кодов ответа, ошибок соединения.
<?php
// PHP 8.1+, extension curl
$ch = curl_init('https://api.example.com/v1/orders');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true, // вернуть тело, а не печатать
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['status' => 'new'], JSON_THROW_ON_ERROR),
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer YOUR_API_TOKEN',
],
CURLOPT_TIMEOUT => 10, // без этого дефолт слишком большой
CURLOPT_CONNECTTIMEOUT => 5,
]);
$body = curl_exec($ch);
if ($body === false) {
// ошибка транспорта: DNS, таймаут, отказ соединения
throw new \RuntimeException('cURL: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status >= 400) {
throw new \RuntimeException("API вернул $status: $body");
}
$data = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
Обратите внимание: cURL не считает код 4xx/5xx ошибкой. Для него сервер ответил — значит всё хорошо. Проверку статуса пишете руками.
Способ 2 — file_get_contents со stream context
Соблазнительно простой вариант без единой зависимости. Работает, но с оговорками, из-за которых для продакшена его брать не стоит.
<?php
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\n",
'content' => json_encode(['status' => 'new'], JSON_THROW_ON_ERROR),
'timeout' => 10,
// важно: без этого при 4xx/5xx вернётся false вместо тела
'ignore_errors' => true,
],
]);
$body = file_get_contents('https://api.example.com/v1/orders', false, $context);
// код ответа приходится доставать из магической $http_response_header
Минусы: нет чистого доступа к коду ответа (приходится парсить массив $http_response_header), allow_url_fopen может быть отключён на хостинге, тела ошибок читаются только с ignore_errors. Годится для разового скрипта, не для сервиса.
Способ 3 — Guzzle
Самый распространённый HTTP-клиент в экосистеме Composer. Ставится composer require guzzlehttp/guzzle. Реализует стандарт PSR-18 и PSR-7. Даёт исключения из коробки, встроенный разбор JSON, ретраи через middleware.
<?php
// guzzlehttp/guzzle 7.x, PHP 8.1+
use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;
$client = new Client([
'base_uri' => 'https://api.example.com/',
'timeout' => 10,
'http_errors' => true, // бросать исключение на 4xx/5xx
]);
try {
$response = $client->post('v1/orders', [
'headers' => ['Authorization' => 'Bearer YOUR_API_TOKEN'],
'json' => ['status' => 'new'], // сам закодирует и проставит Content-Type
]);
// декодирует тело; по умолчанию в ассоциативный массив
$data = json_decode(
(string) $response->getBody(),
true,
512,
JSON_THROW_ON_ERROR
);
} catch (RequestException $e) {
// здесь доступен и запрос, и ответ (если был)
$status = $e->hasResponse() ? $e->getResponse()->getStatusCode() : 0;
throw new \RuntimeException("Ошибка запроса, код $status", 0, $e);
}
Ключ json в опциях сам сериализует массив и ставит заголовок. Опция http_errors => true (включена по умолчанию) превращает 4xx/5xx в исключения — противоположность поведению cURL.
Способ 4 — Symfony HttpClient
Если проект на Symfony или вы хотите ленивые (асинхронные по факту) запросы — composer require symfony/http-client. Отличие в подходе: запрос не выполняется в момент вызова request(), а откладывается до первого обращения к ответу. Это даёт бесплатную параллельность при нескольких запросах.
<?php
// symfony/http-client 6.x или 7.x, PHP 8.1+
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
$client = HttpClient::create(['timeout' => 10]);
try {
$response = $client->request('POST', 'https://api.example.com/v1/orders', [
'auth_bearer' => 'YOUR_API_TOKEN',
'json' => ['status' => 'new'],
]);
// toArray() сам разберёт JSON и бросит исключение при коде >= 300
$data = $response->toArray();
} catch (ExceptionInterface $e) {
throw new \RuntimeException('HttpClient: ' . $e->getMessage(), 0, $e);
}
Метод toArray() делает две вещи разом: проверяет статус (кидает исключение на 3xx-5xx) и декодирует тело. Меньше кода, чем у Guzzle, но семейство исключений своё, не PSR.
Сравнение
| Критерий | cURL | file_get_contents | Guzzle | Symfony HttpClient |
|---|---|---|---|---|
| Зависимости | ext-curl (встроено) | нет | Composer-пакет | Composer-пакет |
| Ошибка на 4xx/5xx | руками | руками | из коробки | из коробки |
| Разбор JSON | руками | руками | руками из getBody | toArray() |
| Стандарт PSR-18 | нет | нет | да | адаптер |
| Параллельные запросы | curl_multi, сложно | нет | пулы, promises | нативно, лениво |
| Многословность | высокая | средняя | низкая | низкая |
Главное различие в цене — не в скорости, а в обработке ошибок. cURL считает, что раз сервер ответил кодом 500, всё в порядке. Guzzle и Symfony по умолчанию считают наоборот. Это меняет структуру вашего кода целиком.
Когда что выбирать
- cURL — когда нельзя тянуть Composer-зависимости (плагин для чужой CMS, legacy-хостинг) или нужен низкоуровневый контроль над транспортом. Готовьтесь писать проверки статуса и таймауты вручную.
- file_get_contents — только для одноразового скрипта, где не важна обработка ошибок и вы уверены, что
allow_url_fopenвключён. - Guzzle — дефолт для большинства проектов на Composer. Огромная база примеров, PSR-совместимость, middleware для ретраев и логирования.
- Symfony HttpClient — если проект уже на Symfony или вам нужно делать десятки запросов параллельно без ручного управления пулом.
Что ломается на практике
- Тихий null от json_decode. Самая частая ошибка новичка — не поставить
JSON_THROW_ON_ERRORи потом ловитьTypeErrorгде-то ниже по коду, где непонятно, что произошло. - Отсутствие таймаута. У cURL дефолтный
CURLOPT_TIMEOUT— 0, то есть без ограничения. Один зависший внешний API заблокирует ваш процесс. Всегда ставьте таймаут явно. - SSL на старом хостинге. Устаревший бандл CA-сертификатов даёт ошибку проверки сертификата. Лечится обновлением
curl.cainfoв php.ini, а не отключением проверки — отключатьCURLOPT_SSL_VERIFYPEERв проде нельзя. - Content-Type сервера врёт. Некоторые API отдают HTML-страницу ошибки с заголовком
application/json. Проверяйте код ответа до разбора тела. - Потеря точности больших ID. Если API возвращает 64-битные числовые идентификаторы, без
JSON_BIGINT_AS_STRINGони округлятся.
Что делать дальше
Выбрали клиент — настройте вокруг него три вещи. Первое: единый враппер, который логирует запрос, ответ и время выполнения, — иначе отладка чужого API превратится в гадание. Второе: ретраи с экспоненциальной задержкой на коды 429 и 5xx (в Guzzle это middleware RetryMiddleware, в Symfony — RetryableHttpClient). Третье: замерьте, сколько времени ваш сервис проводит в ожидании внешних API под реальной нагрузкой — часто именно это, а не база данных, определяет время ответа. Для параллельных вызовов сравните curl_multi, Guzzle-пулы и ленивость Symfony на вашем числе запросов.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: PHP Manual: json_decode и константы JSON, Guzzle Documentation — Request Options
Частые вопросы
А если у меня PHP 7.4 — флаг JSON_THROW_ON_ERROR работает?
Почему json_decode возвращает null, хотя JSON на вид валидный?
Guzzle или Symfony HttpClient — что быстрее?
Можно ли обойтись без Composer и делать API-запросы только на cURL?
Что делать, если запрос падает с ошибкой проверки SSL-сертификата?
Как поймать таймаут отдельно от других ошибок?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.