Перейти к содержимому
Поиск
Php Разработка

Guzzle в PHP: сравнение с cURL и 12 практических примеров

Guzzle и стандартный PHP cURL на одном запросе. GET, POST, JSON, файлы, авторизация, ошибки и async: 12 примеров, локальный API и готовый проект через Composer.

Александр Некрасов
Guzzle — HTTP-запросы в PHP: обмен запросом и ответом между клиентом и сервером

Получить пользователя из API, отправить JSON, передать файл, дождаться нескольких ответов — типичные задачи PHP-приложения. Guzzle помогает описывать такие запросы понятными массивами настроек, повторно использовать клиент и отдельно обрабатывать проблемы сети, HTTP и данных.

Начнём с главного: Guzzle — PHP-библиотека HTTP-клиента, cURL — инструмент передачи данных и расширение PHP для работы с libcurl. Они могут работать вместе: Guzzle часто отправляет запросы через cURL. Удобство библиотеки проявляется прежде всего в сборке запросов и организации кода, а не в обещании «ускорить любой API».

Выберите свою задачу

Примеры рассчитаны на PHP 8.2+ и Guzzle 7. Команды выполняются в терминале из папки проекта. Для практики приложен учебный сервер на вашем компьютере: внешние API и регистрация не нужны. На самой странице PHP не запускается. Если Composer ещё не установлен, откройте руководство «Composer в PHP: от первой команды до рабочего проекта».

1. Установка через Composer

В существующем PHP-проекте выполните:

composer require "guzzlehttp/guzzle:7.*"
composer show guzzlehttp/guzzle

Composer установит библиотеку и её зависимости в vendor/. Подключение vendor/autoload.php позволит использовать классы без ручных require для каждого файла. Не скачивайте отдельный Client.php: ему нужны остальные части пакета.

Для примеров этой статьи проще взять готовый проект. Скачайте ZIP, распакуйте, перейдите в папку, где лежит composer.json, и установите зафиксированные зависимости:

composer install
composer check-platform-reqs
php --ri curl

В архиве есть composer.lock, но нет vendor/: интернет нужен для первой установки пакетов. Для сравнения с нативным cURL и воспроизведения всех транспортных проверок включите расширение ext-curl именно у PHP командной строки. Сам Guzzle поддерживает разные обработчики транспорта; отсутствие cURL не всегда означает невозможность выполнить HTTP-запрос.

Документация: Guzzle: установка.

Документация: Нужен ли Guzzle cURL.

2. Guzzle и обычный cURL: понятное сравнение

Здесь сравниваем Guzzle с PHP-функциями curl_init(), curl_setopt_array(), curl_exec(). Команда curl в консоли — отдельная программа, хотя она решает похожие задачи.

Задача PHP cURL Guzzle
Параметры URL Собрать строку через http_build_query() Передать массив query
Отправить JSON Закодировать тело и задать Content-Type Передать массив json
Настройки для всего API Организовать повторное применение параметров самостоятельно Задать base_uri, заголовки и timeout при создании клиента
Ответ Тело из curl_exec(), метаданные через curl_getinfo() Объект ответа: статус, заголовки, поток тела
Сетевая ошибка Проверить === false, затем errno/error Обработать исключение
HTTP 404/500 По умолчанию проверить код отдельно При стандартном клиенте и http_errors=true — исключение
Несколько запросов curl_multi_* и собственная координация Promises, Utils::settle(), Pool
Тест без сети Обычно нужна своя обёртка/подмена MockHandler и история запросов

Один GET двумя способами. В обоих случаях отправляем параметры и два заголовка, ограничиваем ожидание, запрещаем перенаправления, проверяем статус, JSON и структуру пользователя. Ниже — центральные фрагменты; полные запускаемые файлы находятся в архиве. Сервер запустим в следующем разделе.

Чтобы повторить сравнение целиком: запустите локальный сервер, затем во втором терминале выполните composer compare. В обоих фрагментах parseUser() — наша общая функция проверки ответа, а $query — массив параметров. Их определения доступны ниже.

Общий код для двух вариантов сравнения

Это начало отдельного PHP-файла. После него разместите выбранный вариант внутри try с обработчиком catch (Throwable $e). Полный файл с обработкой ошибок уже есть в архиве: curl-vs-guzzle.php.

<?php
declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

function parseUser(string $body): array
{
    $user = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
    if (!is_array($user) || !isset($user['id'], $user['name'])
        || !is_int($user['id']) || !is_string($user['name'])) {
        throw new UnexpectedValueException('Ожидаются id:int и name:string.');
    }
    return $user;
}

$query = ['lang' => 'ru', 'include' => 'profile'];

Нативный PHP cURL:

$url = 'http://127.0.0.1:8095/users/7?'
    . http_build_query($query, '', '&', PHP_QUERY_RFC3986);
$curl = curl_init($url);
if ($curl === false) {
    throw new RuntimeException('Не удалось создать cURL handle.');
}
try {
    curl_setopt_array($curl, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => ['Accept: application/json', 'X-Request-ID: demo-001'],
        CURLOPT_CONNECTTIMEOUT_MS => 1000,
        CURLOPT_TIMEOUT_MS => 2000,
        CURLOPT_FOLLOWLOCATION => false,
        CURLOPT_FAILONERROR => false,
        CURLOPT_PROXY => '', // Только для учебного localhost.
    ]);
    $body = curl_exec($curl);
    if ($body === false) {
        throw new RuntimeException('cURL ' . curl_errno($curl) . ': ' . curl_error($curl));
    }
    $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
    if ($status < 200 || $status >= 300) {
        throw new RuntimeException('HTTP ' . $status);
    }
    $user = parseUser($body);
    echo $user['id'] . ': ' . $user['name'] . PHP_EOL;
} finally {
    curl_close($curl);
}

Тот же запрос через Guzzle:

$client = new GuzzleHttp\Client([
    'base_uri' => 'http://127.0.0.1:8095/',
    'headers' => ['Accept' => 'application/json', 'X-Request-ID' => 'demo-001'],
    'connect_timeout' => 1.0,
    'timeout' => 2.0,
    'allow_redirects' => false,
    'http_errors' => false, // Статус проверяем так же, как в примере cURL.
    'proxy' => '',
]);
$response = $client->get('users/7', ['query' => $query]);
$status = $response->getStatusCode();
if ($status < 200 || $status >= 300) {
    throw new RuntimeException('HTTP ' . $status);
}
$user = parseUser((string) $response->getBody());
echo $user['id'] . ': ' . $user['name'] . PHP_EOL;

В Guzzle URL, параметры, заголовки и ограничения собраны в одном объекте клиента и массиве запроса. Проверки полезных данных остаются необходимыми в обоих вариантах. Библиотека не знает, что ваш API обязан вернуть целочисленный id и строковый name.

HTTP-ошибка и ошибка передачи — разные вещи. Для cURL полученный ответ 404 сам по себе обычно не делает curl_exec() равным false. У Guzzle стандартный режим превращает 4xx/5xx в исключения; с http_errors=false статус проверяете самостоятельно. Поэтому сравнивать лишь число строк без одинаковых проверок некорректно.

Что выбрать? Нативный cURL удобен для небольшого автономного скрипта, специфических возможностей libcurl или проекта с готовой проверенной обёрткой. Guzzle удобен, когда запросов много: несколько методов API, общая авторизация, файлы, тесты, единая обработка ответов. Менять уже работающий код только ради названия библиотеки необязательно.

Документация: PHP: curl_exec и HTTP-коды.

3. Первый запрос: полный рабочий файл

В распакованном проекте откройте первый терминал и запустите учебный API. Оставьте это окно открытым:

php -S 127.0.0.1:8095 local-server.php

Сервер слушает только ваш компьютер. Маршрут /users/7 возвращает пользователя, /echo показывает принятые параметры, /upload принимает файл. Это учебные адреса из архива, а не универсальные маршруты любого API. Встроенный сервер PHP предназначен для локальной разработки.

Файл request.php целиком:

<?php
declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;

$client = new Client([
    'base_uri' => 'http://127.0.0.1:8095/',
    'connect_timeout' => 2.0,
    'timeout' => 5.0,
    'allow_redirects' => false,
    'proxy' => '', // Локальный стенд: не используем системный HTTP_PROXY.
    'headers' => ['Accept' => 'application/json'],
]);

try {
    $response = $client->get('users/7');
    $user = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR);
    if (!is_array($user) || !isset($user['id'], $user['name'])
        || !is_int($user['id']) || !is_string($user['name'])) {
        throw new UnexpectedValueException('Ответ не соответствует ожидаемой структуре пользователя.');
    }
    echo $user['id'] . ': ' . $user['name'] . PHP_EOL;
} catch (GuzzleException $e) {
    fwrite(STDERR, 'Ошибка HTTP/сети: ' . $e->getMessage() . PHP_EOL);
    exit(1);
} catch (JsonException | UnexpectedValueException $e) {
    fwrite(STDERR, 'Ошибка данных: ' . $e->getMessage() . PHP_EOL);
    exit(1);
}

Во втором терминале, в той же папке, выполните:

composer request

Ожидаемый результат: 7: Александр. Эквивалентный запуск — php request.php. После практики остановите сервер сочетанием Ctrl + C в первом терминале.

Как пробовать следующие фрагменты. Сделайте копию request.php с именем experiment.php. Оставьте подключение автозагрузчика и создание $client, затем заменяйте всё содержимое блока try целиком выбранным фрагментом; существующие catch сохраняйте. Запускайте php experiment.php. Так исходный composer request продолжит работать. Если пример показывает собственный try/catch, замените соответствующий блок полностью. Параметр proxy отключает системный прокси только для учебного localhost.

4. Как удобно собирать запрос

У запроса четыре понятные части: метод → адрес → параметры → тело. Клиент хранит общие настройки, третий аргумент request() описывает конкретный вызов.

$response = $client->request('GET', 'echo', [
    'query' => [
        'search' => 'Иван Петров',
        'page' => 2,
        'limit' => 10,
    ],
    'headers' => ['X-Request-ID' => 'article-001'],
    'timeout' => 3.0,
]);

echo (string) $response->getBody();

Сервер вернёт method: GET и принятые query: имя с пробелом, страницу и лимит. Кодирование параметров выполняет библиотека — не нужно вручную склеивать ?search=... и &page=.... Числа в query на принимающей стороне обычно оказываются строками.

В этом вызове timeout=3 переопределяет общий timeout=5 только для одного запроса, а X-Request-ID дополняет клиентский Accept. Следующий вызов без этих опций снова использует общие настройки.

$client->get('echo', $options) — короткая запись $client->request('GET', 'echo', $options). Аналогично доступны post, put, patch, delete. Метод выбирается по контракту API: PATCH обычно меняет часть объекта, DELETE удаляет его, но конкретные пути и ответы определяет сервис.

Параметры можно собирать по условиям:

$filters = ['page' => 1, 'limit' => 20];
$search = trim('Иван');
if ($search !== '') {
    $filters['search'] = $search;
}
$response = $client->get('echo', ['query' => $filters]);

Ловушка с косой чертой. Для условного base_uri=https://api.example.test/v1/:

Путь в запросе Итоговый адрес
users/7 https://api.example.test/v1/users/7
/users/7 https://api.example.test/users/7

Начальная / заменяет путь от корня. Если в самой базе убрать последнюю / после v1, относительный путь тоже может заменить этот сегмент. Домен example.test здесь иллюстрирует правило и не является рабочим API. Абсолютный URL заменяет базу целиком: не подставляйте произвольный пользовательский URL в клиент с секретными заголовками.

Документация: Клиент и base_uri.

5. POST: JSON, форма или готовое тело

Что принимает API Опция Guzzle Типичный случай
JSON json Создать заявку, обновить профиль
Обычная HTML-форма form_params Поля application/x-www-form-urlencoded
Файл и поля multipart Документ, фотография
Готовая строка или поток body XML, собственный JSON, бинарные данные

JSON. В json передавайте PHP-массив, а не уже закодированную строку:

$response = $client->post('echo', [
    'json' => [
        'name' => 'Александр',
        'email' => 'demo@example.test',
        'active' => true,
    ],
]);
echo (string) $response->getBody();

Локальный API вернёт объект в поле json. При обычных настройках Guzzle задаёт соответствующий Content-Type. Обратите внимание: Accept описывает желаемый ответ, Content-Type — отправляемое тело.

Форма. Тот же подход, но другой формат:

$response = $client->post('echo', [
    'form_params' => ['name' => 'Александр', 'city' => 'Москва'],
]);
echo (string) $response->getBody();

Теперь значения окажутся в поле form. Выбор между json и form_params определяет документация сервера.

Нужны собственные флаги json_encode?

Сформируйте строку самостоятельно и передайте через body. Это отдельный вариант запроса:

$payload = json_encode(
    ['name' => 'Александр'],
    JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR
);
$response = $client->post('echo', [
    'headers' => ['Content-Type' => 'application/json'],
    'body' => $payload,
]);
В одном запросе выбирайте один способ задания тела: json, form_params, multipart или body. Параметры query и заголовки можно добавлять к любому из этих вариантов.

6. Заголовки, авторизация и cookies

Общие заголовки задавайте в клиенте, специфичные — в конкретном вызове. Секретный токен храните в переменной окружения или хранилище секретов приложения. Ниже — самостоятельная настройка клиента для вашего настоящего HTTPS API; замените демонстрационный домен на доверенный адрес.

$token = getenv('API_TOKEN');
if ($token === false || $token === '') {
    throw new RuntimeException('Не задана переменная API_TOKEN.');
}
$api = new GuzzleHttp\Client([
    'base_uri' => 'https://api.example.test/v1/',
    'timeout' => 10.0,
    'allow_redirects' => false,
    'headers' => [
        'Accept' => 'application/json',
        'Authorization' => 'Bearer ' . $token,
    ],
]);
$response = $api->get('users/7');

Не помещайте токен в URL и не выводите целиком запросы, исключения или debug-трассировку в публичную страницу. Ошибка может содержать адрес и фрагмент ответа. Пример request.php печатает детали в локальный терминал; в приложении пользователю достаточно понятного сообщения, а разработчику — очищенной записи в журнале.

Как использовать Basic Auth?

Для сервера, который требует Basic Auth, передайте 'auth' => [$login, $password]. Значения берутся из защищённых настроек; для удалённого сервера используйте HTTPS. Это отдельный способ авторизации — не добавляйте его без необходимости к Bearer-токену.

Как сохранять cookies между запросами?

Создайте $jar = new GuzzleHttp\Cookie\CookieJar(); и передайте 'cookies' => $jar клиенту. Один jar хранит cookies между вызовами этого клиента в процессе. Для разных пользовательских сессий используйте отдельные хранилища.

7. Ответ: статус, заголовки, JSON

$response = $client->get('users/7');
$status = $response->getStatusCode();
$contentType = $response->getHeaderLine('Content-Type');
$body = (string) $response->getBody();
$data = json_decode($body, true, 512, JSON_THROW_ON_ERROR);

echo $status . PHP_EOL;
echo $contentType . PHP_EOL;
echo $data['name'] . PHP_EOL;

Для нашего маршрута получите 200, JSON Content-Type и имя. Объект ответа соответствует PSR-7, а тело представляет собой поток. getContents() читает оставшуюся часть от текущей позиции; после чтения она может быть пустой. Для небольшого JSON удобно один раз получить строку и работать с ней. Для больших файлов используйте sink.

Проверяйте три уровня. HTTP-статус говорит, как сервер обработал запрос. json_decode(..., JSON_THROW_ON_ERROR) проверяет синтаксис JSON. Проверка полей и типов подтверждает пригодность данных для вашей задачи — она показана в полном request.php. JSON {"error":"..."} с HTTP 200 вполне может быть бизнес-ошибкой по правилам конкретного API.

Ответ 204 обычно не содержит тела: не пытайтесь декодировать пустую строку как JSON. Сначала обработайте этот статус, если он предусмотрен контрактом вашего метода.

Документация: Ответы и потоки PSR-7.

Документация: PHP: json_decode.

8. Исключения: сеть, HTTP и данные

Ниже — альтернативный блок try/catch для уже созданного $client. Маршрут status/404 специально отвечает ошибкой. Более конкретные исключения ставьте раньше общих.

try {
    $response = $client->get('status/404');
    $data = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR);
} catch (GuzzleHttp\Exception\ConnectException $e) {
    echo 'Не удалось установить соединение.';
} catch (GuzzleHttp\Exception\RequestException $e) {
    $response = $e->getResponse();
    echo $response === null
        ? 'Ошибка запроса без HTTP-ответа.'
        : 'Сервер вернул HTTP ' . $response->getStatusCode();
} catch (GuzzleHttp\Exception\GuzzleException $e) {
    echo 'Другая ошибка выполнения HTTP-запроса.';
} catch (JsonException $e) {
    echo 'Ответ не является корректным JSON.';
}

Для этого вызова результат — Сервер вернул HTTP 404. ClientException для 4xx и ServerException для 5xx наследуются от RequestException. ConnectException в эту ветку не входит, поэтому один catch (RequestException) не покрывает все сетевые отказы.

Если 404 — ожидаемый результат поиска, можно получать ответ без исключения:

$response = $client->get('status/404', ['http_errors' => false]);
$status = $response->getStatusCode();
if ($status === 404) {
    echo 'Пользователь не найден.';
} elseif ($status < 200 || $status >= 300) {
    throw new RuntimeException('Неожиданный HTTP ' . $status);
}

http_errors=false меняет реакцию на HTTP-код, но не устраняет сетевые ошибки. Этот вызов всё равно размещайте внутри обработчика ошибок транспорта.

Почему sendRequest() ведёт себя иначе?

sendRequest() реализует PSR-18: ответы 4xx/5xx возвращаются как обычные ответы, перенаправления автоматически не выполняются. В этой статье для привычных опций Guzzle используются request(), get() и post(). При переходе к PSR-18 явно проверьте логику статусов и перенаправлений.

9. Таймауты, HTTPS и перенаправления

Задавайте лимиты осознанно: приложение не должно бесконечно ждать сторонний сервис. В учебном клиенте timeout=5 ограничивает время запроса, connect_timeout=2 — установление соединения. Поддержка connect_timeout зависит от транспорта; встроенный cURL handler его поддерживает.

Воспроизведите настоящий timeout: локальный /slow ждёт 0,4 секунды, а клиенту даём 0,1 секунды.

try {
    $client->get('slow', ['timeout' => 0.1]);
} catch (GuzzleHttp\Exception\GuzzleException $e) {
    echo 'Запрос не завершился в отведённое время.';
}

Это демонстрация с заранее известной задержкой. В обычном приложении такой общий catch не доказывает, что причиной был timeout: проверяйте класс, диагностический контекст и журнал. Стадия сбоя и handler влияют на тип исключения.

  • HTTPS: оставляйте проверку сертификатов включённой. При ошибке сертификата исправьте цепочку CA или настройку окружения; verify=false не является рабочим исправлением.
  • Перенаправления: для API с известным адресом удобно явно поставить allow_redirects=false и разобраться с 3xx. Если разрешаете переходы, задайте допустимые протоколы и предел.
  • Прокси: настройки окружения могут менять путь запроса. В локальном стенде он отключён; в корпоративной сети используйте согласованные настройки.

Документация: Опции запросов: timeout, verify, redirects.

10. Отправка и скачивание файлов

Отправка multipart. В архиве есть fixtures/note.txt. Фрагмент открывает его как поток и отправляет вместе с обычным полем:

$file = fopen(__DIR__ . '/fixtures/note.txt', 'rb');
if ($file === false) {
    throw new RuntimeException('Не удалось открыть файл.');
}
try {
    $response = $client->post('upload', [
        'multipart' => [
            ['name' => 'file', 'contents' => $file, 'filename' => 'upload.txt'],
            ['name' => 'description', 'contents' => 'Учебный документ'],
        ],
    ]);
    echo (string) $response->getBody();
} finally {
    if (is_resource($file)) {
        fclose($file);
    }
}

Сервер вернёт имя, размер и код загрузки; error=0 означает успешный приём PHP. Не задавайте вручную Content-Type: multipart/form-data: библиотека должна добавить правильную границу частей.

Скачивание. sink записывает тело в файл, не требуя сначала собрать весь ответ в строку. Временный файл нужен, чтобы проверить результат перед дальнейшим использованием:

$path = tempnam(sys_get_temp_dir(), 'guzzle-');
if ($path === false) {
    throw new RuntimeException('Не удалось создать временный файл.');
}
try {
    $response = $client->get('file', ['sink' => $path]);
    if ($response->getStatusCode() !== 200) {
        throw new RuntimeException('Неожиданный статус загрузки.');
    }
    echo file_get_contents($path); // Только маленький учебный файл.
} finally {
    if (is_file($path)) {
        unlink($path);
    }
}

Результат: Guzzle download demo. В рабочем приложении после проверки статуса, типа и допустимого размера переместите файл в своё хранилище. Даже ошибочный ответ или оборванная передача могут оставить данные на диске — само наличие файла ещё не подтверждает успех.

11. Несколько запросов: async и Pool

Если запросы независимы, их можно запустить до ожидания результата. Utils::settle() позволяет получить итоги всей группы, включая отказы. Следующий фрагмент выполняется с нашим локальным клиентом:

$promises = [
    'user' => $client->getAsync('users/7'),
    'missing' => $client->getAsync('status/404'),
];
$results = GuzzleHttp\Promise\Utils::settle($promises)->wait();

foreach ($results as $name => $result) {
    if ($result['state'] === 'fulfilled') {
        echo $name . ': HTTP ' . $result['value']->getStatusCode() . PHP_EOL;
    } else {
        echo $name . ': запрос завершился ошибкой' . PHP_EOL;
    }
}

Будет успешный user: HTTP 200 и отказ missing. wait() дожидается завершения группы. Для большого списка используйте ограничение числа запросов:

$requests = function () use ($client) {
    foreach (range(1, 10) as $number) {
        yield function () use ($client, $number) {
            return $client->getAsync('echo', ['query' => ['number' => $number]]);
        };
    }
};
$pool = new GuzzleHttp\Pool($client, $requests(), [
    'concurrency' => 3,
    'fulfilled' => function ($response, $index): void {
        echo $index . ': HTTP ' . $response->getStatusCode() . PHP_EOL;
    },
    'rejected' => function ($reason, $index): void {
        echo $index . ': ошибка запроса' . PHP_EOL;
    },
]);
$pool->promise()->wait();

Здесь одновременно допускается до трёх незавершённых операций. Реальная параллельность зависит от handler и сервера. Однопоточный учебный php -S и MockHandler не подходят для вывода «стало в N раз быстрее». Ограничение concurrency также не заменяет соблюдение лимита запросов в секунду у поставщика API.

Документация: Promises и Pool.

12. Повторы, журнал и устройство клиента

Guzzle даёт механизм middleware — обработчиков вокруг отправки запроса. Через них удобно организовать журнал, измерение времени и повторные попытки. Обычный клиент не означает автоматическую безопасную политику retry для вашего API.

  • Ограничьте число повторов и общее время выполнения.
  • Повторяйте только подходящие временные отказы, учитывая договорённости сервиса и Retry-After.
  • Для чтения повтор обычно проще; создание заказа через POST требует идемпотентного ключа или другого механизма API. Потерянный ответ не доказывает, что сервер не выполнил операцию.
  • Записывайте метод, разрешённый путь, статус, длительность и идентификатор запроса. Исключайте токены, cookies и чувствительное тело.

Для большого приложения вынесите вызовы в отдельный класс, например UserApi, и передавайте ему HTTP-клиент через конструктор. Тогда контроллер работает с понятным findUser($id), а URL, форматы и обработка сетевых ошибок остаются в одном месте. Для теста можно подставить клиент с MockHandler.

Почему при собственном handler пропало привычное поведение?

Опции вроде обработки HTTP-ошибок и перенаправлений обеспечиваются middleware. Для собственного транспорта создавайте стандартный стек через GuzzleHttp\HandlerStack::create($handler), если рассчитываете сохранить стандартное поведение. Простая замена обработчика без стека может изменить результат.

Документация: Handlers и middleware.

13. Готовый проект: 12 сценариев и живой HTTP

Скачать примеры Guzzle — ZIP

В архиве исходники, fixtures, README, composer.json и composer.lock. Два режима дополняют друг друга: mock-сценарии проверяют формирование запросов и обработку заданных ответов, локальный сервер позволяет действительно отправить HTTP, передать файл и дождаться таймаута.

composer demo -- list
composer demo -- get
composer demo -- all
Сценарий Команда Что проверяет
GET composer demo -- get Путь, query, ответ пользователя
JSON composer demo -- json POST, тело JSON и Content-Type
Форма composer demo -- form Кодирование полей формы
Заголовки composer demo -- headers Демонстрационная авторизация и заголовки
Файл composer demo -- multipart Части multipart и имя файла
HTTP 404 composer demo -- 404 ClientException и режим без http_errors
HTTP 503 composer demo -- 503 ServerException и Retry-After
Отказ сети composer demo -- network Заданное исключение ConnectException
Сломанный JSON composer demo -- invalid-json JsonException
Неверные поля composer demo -- invalid-schema Проверку структуры ответа
Скачивание composer demo -- download Тело, записанное через sink
Async composer demo -- async Успех и отказ в одной группе promises
Почему сценарий ошибки может завершаться кодом 0? Это самопроверка: ожидаемое исключение поймано и проверено — тест прошёл. Код 1 означает, что поведение разошлось с ожидаемым. Обычный request.php при неудачном запросе завершается кодом 1. Mock-сценарий network не выключает интернет и не измеряет реальный сетевой timeout.

Настоящие HTTP-проверки. При запущенном в первом терминале сервере выполните во втором:

composer request
composer compare
composer live

compare запускает одинаковые вызовы через нативный cURL и Guzzle; live проверяет реальную работу учебных маршрутов. Подробный порядок и ожидаемые результаты приведены в README.

Проверено 6 ноября 2026 года: PHP 8.3.33, Composer 2.10.3, Guzzle 7.16.1, Windows и расширение cURL. В архивном проекте прошли 12 из 12 mock-сценариев и 10 из 10 локальных HTTP-проверок, включая multipart, скачивание, HTTP-ошибки и настоящий timeout. Отдельно выполнены запросы из сравнения cURL/Guzzle и применимые PHP-фрагменты статьи, включая JSON, форму, собственное тело, async и Pool. Синтаксис PHP-фрагментов проверен. Пример Bearer-авторизации с демонстрационным доменом проверен только синтаксически — внешнему сервису запрос не отправлялся.

Установленные зависимости прошли composer validate --strict, composer check-platform-reqs и composer audit --locked; известных advisories на момент проверки не обнаружено. Другие версии PHP, внешний DNS, HTTPS/TLS, отдельный таймаут соединения и ускорение параллельных запросов этим стендом не проверялись.

Документация: MockHandler и история запросов.

14. Шпаргалка и частые ошибки

Нужно Используйте
GET с фильтром $client->get('path', ['query' => $filters])
POST JSON $client->post('path', ['json' => $data])
Обычная форма ['form_params' => $fields]
Ожидаемый HTTP 404 http_errors => false + проверка статуса
Ограничить ожидание timeout; для cURL также connect_timeout
Сохранить ответ в файл sink + проверка результата
Получить все итоги группы Utils::settle($promises)->wait()
Class GuzzleHttp\Client not found

Проверьте composer install, наличие vendor/autoload.php и путь относительно текущего PHP-файла. Для вложенной папки __DIR__ указывает именно на неё, а не на корень проекта.

cURL error 7 / Connection refused на localhost

Запустите первый терминал с сервером, проверьте порт 8095 и папку с local-server.php. Если PHP выполняется внутри контейнера, его 127.0.0.1 указывает на сам контейнер.

Сервер отвечает 401 или 403

Проверьте способ авторизации, срок действия токена и права на метод API. Повторение того же запроса без изменения доступа обычно не помогает. Не вставляйте действующий токен в публичный пример или комментарий.

HTTP 400/415 или сервер не видит поля

Проверьте контракт тела: JSON, form-urlencoded и multipart — разные форматы. Не передавайте json_encode($data) в опцию json; не смешивайте способы тела.

HTTP 404, хотя endpoint существует

Посмотрите итоговый путь: ведущая косая черта могла убрать /v1/ из base_uri. Затем проверьте метод запроса и идентификатор ресурса.

Вместо JSON пришла HTML-страница

Посмотрите статус и Content-Type: возможны страница входа, ответ прокси, неверный адрес или ошибка сервера. Не подавляйте ошибку JSON, пока не выяснено, что именно получено.

Ошибка сертификата или cURL error 60

Проверьте часы системы, доверенные CA, сертификат сервера и настройки curl.cainfo/openssl.cafile нужного PHP. Проверку TLS сохраняйте включённой.

Полезные команды диагностики окружения:

php --version
php --ini
php --ri curl
composer diagnose
composer check-platform-reqs
composer audit

15. Источники и следующий шаг

Для закрепления поменяйте фильтры в GET, отправьте свой небольшой JSON и обработайте ожидаемый 404. Затем перенесите один метод настоящего API в отдельный класс с понятным результатом и обработкой ошибок. Такой переход хорошо показывает, зачем нужен единый HTTP-клиент.