Guzzle Best Practices
Guzzle is the de-facto PHP HTTP client. Use these rules to build robust, testable, and maintainable HTTP integrations.
1. Client Instantiation & Configuration
Always inject an immutable Guzzle client as a service. Configure it once with global defaults, especially timeouts.
✅ GOOD: Service-Oriented Client with Essential Defaults
<?php
use GuzzleHttp\Client;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use Psr\Log\LoggerInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use GuzzleHttp\Exception\ConnectException;
use Exception;
final class MyApiClientFactory
{
public static function create(LoggerInterface $logger): Client
{
$stack = HandlerStack::create();
// 1. Logging: Log all requests and responses
$stack->push(Middleware::log(
$logger,
new \GuzzleHttp\MessageFormatter(
'{method} {uri} HTTP/{version} {code} {res_header_Content-Length}'
)
));
// 2. Retry Logic: Robust handling for transient errors (5xx, connection issues)
$stack->push(Middleware::retry(
function (
$retries,
RequestInterface $request,
ResponseInterface $response = null,
Exception $exception = null
) {
// Limit to 3 retries
if ($retries >= 3) {
return false;
}
// Retry on connection exceptions
if ($exception instanceof ConnectException) {
return true;
}
// Retry on server errors (5xx)
if ($response && $response->getStatusCode() >= 500) {
return true;
}
return false;
},
// Exponential backoff: 100ms, 200ms, 400ms
function ($retries) {
return 100 * (2 ** ($retries - 1));
}
));
return new Client([
'base_uri' => 'https://api.example.com/v1/',
'timeout' => 10.0, // Total request timeout in seconds
'connect_timeout' => 5.0, // Connection timeout in seconds
'headers' => [
'Accept' => 'application/json',
'User-Agent' => 'MyApp/1.0 (PHP Guzzle)',
],
'handler' => $stack,
'http_errors' => true, // Throw exceptions for 4xx/5xx responses
]);
}
}
// Usage in a service container or dependency injection
// $client = MyApiClientFactory::create($myLogger);
// $response = $client->get('users');
❌ BAD: Global Singletons & Missing Timeouts
<?php
use GuzzleHttp\Client;
// Client created ad-hoc, no shared configuration, no timeouts
// Hard to test, prone to hanging requests
class LegacyService
{
public function fetchUsers(): array
{
$client = new Client(); // ❌ BAD: No timeouts, no base_uri, no shared config
$response = $client->get('https://api.example.com/v1/users');
return json_decode($response->getBody()->getContents(), true);
}
}
2. Middleware for Cross-Cutting Concerns
Leverage HandlerStack and GuzzleHttp\Middleware for reusable logic like retries, logging, and authentication. This keeps your request code DRY.
✅ GOOD: Centralized Middleware for Auth & Retries
(See MyApiClientFactory::create example above for comprehensive middleware usage.)
<?php
use GuzzleHttp\Middleware;
use Psr\Http\Message\RequestInterface;
// Example of adding an authentication header via middleware
$stack->push(Middleware::mapRequest(function (RequestInterface $request) {
$token = 'your_api_token_here'; // Get from secure config
return $request->withHeader('Authorization', 'Bearer ' . $token);
}));
❌ BAD: Duplicating Logic Per Request
<?php
use GuzzleHttp\Client;
class UserService
{
private Client $client;
public function __construct(Client $client)
{
$this->client = $client;
}
public function getUser(string $id): array
{
// ❌ BAD: Authentication header added manually for every request type
$response = $this->client->get("users/{$id}", [
'headers' => [
'Authorization' => 'Bearer ' . 'your_api_token_here',
],
]);
return json_decode($response->getBody()->getContents(), true);
}
}
3. "Bring Your Own Client" (BYOC) for Libraries/SDKs
When building reusable libraries or SDKs, never hard-depend on Guzzle. Instead, type-hint Psr\Http\Client\ClientInterface (PSR-18) and Psr\Http\Message\RequestFactoryInterface (PSR-17). Let the consumer inject their preferred HTTP client.
✅ GOOD: PSR-18/17 Compliant SDK
<?php
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\ResponseInterface;
final class MySdkClient
{
private const string BASE_URI = 'https://api.myservice.com';
public function __construct(
private ClientInterface $httpClient,
private RequestFactoryInterface $requestFactory,
private StreamFactoryInterface $streamFactory,
) {}
public function fetchData(string $endpoint, array $data = []): ResponseInterface
{
$body = $this->streamFactory->createStream(json_encode($data));
$request = $this->requestFactory->createRequest('POST', self::BASE_URI . $endpoint)
->withHeader('Content-Type', 'application/json')
->withBody($body);
return $this->httpClient->sendRequest($request);
}
}
// Consumer code (e.g., in a Symfony/Laravel app)
// use GuzzleHttp\Psr7\HttpFactory;
// use GuzzleHttp\Client as GuzzleClient;
// $guzzleClient = new GuzzleClient(/* ... config ... */);
// $httpFactory = new HttpFactory(); // PSR-17 implementation
// $sdk = new MySdkClient($guzzleClient, $httpFactory, $httpFactory);
❌ BAD: Hard-Dependence on Guzzle in SDKs
<?php
use GuzzleHttp\Client; // ❌ BAD: Direct Guzzle dependency
final class MyBadSdkClient
{
private Client $httpClient;
public function __construct(Client $httpClient) // ❌ BAD: Forces Guzzle on consumers
{
$this->httpClient = $httpClient;
}
public function fetchData(string $endpoint): array
{
$response = $this->httpClient->get('https://api.myservice.com' . $endpoint);
return json_decode($response->getBody()->getContents(), true);
}
}
4. Error Handling
Always catch specific Guzzle exceptions, especially RequestException (for HTTP errors) and ConnectException (for network issues).
✅ GOOD: Specific Exception Handling
<?php
use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Exception\ConnectException;
final class DataFetcher
{
private Client $client;
public function __construct(Client $client)
{
$this->client = $client;
}
public function fetchImportantData(): ?array
{
try {
$response = $this->client->get('critical-endpoint');
return json_decode($response->getBody()->getContents(), true);
} catch (ConnectException $e) {
// Log network error, notify ops, potentially retry (if not handled by middleware)
error_log("Network error fetching data: " . $e->getMessage());
return null; // Or throw a custom application exception
} catch (RequestException $e) {
// Log HTTP error (4xx, 5xx), inspect response
error_log("HTTP error fetching data: " . $e->getMessage() . " Response: " . $e->getResponse()?->getBody());
if ($e->hasResponse() && $e->getResponse()->getStatusCode() === 404) {
return []; // Handle specific 404 case
}
return null; // Or throw a custom application exception
} catch (\Throwable $e) {
// Catch any other unexpected errors
error_log("Unexpected error: " . $e->getMessage());
throw $e; // Re-throw for higher-level handling
}
}
}
❌ BAD: Catching Generic Exceptions
<?php
// ... (class definition)
public function fetchImportantData(): ?array
{
try {
$response = $this->client->get('critical-endpoint');
return json_decode($response->getBody()->getContents(), true);
} catch (\Exception $e) { // ❌ BAD: Catches too broadly, hides specific issues
error_log("Error fetching data: " . $e->getMessage());
return null;
}
}
5. Testing Guzzle Clients
Use GuzzleHttp\Handler\MockHandler for deterministic and fast unit tests. This avoids hitting real APIs and ensures consistent test results.
✅ GOOD: Mocking Responses with MockHandler
<?php
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\TestCase;
final class MyServiceTest extends TestCase
{
public function testFetchUsersSuccessfully(): void
{
// Create a mock handler with a successful response
$mock = new MockHandler([
new Response(200, ['Content-Type' => 'application/json'], json_encode(['users' => ['Alice', 'Bob']])),
]);
$handlerStack = HandlerStack::create($mock);
$client = new Client(['handler' => $handlerStack]);
// Inject the mocked client into your service
$service = new MyService($client);
$users = $service->fetchUsers();
$this->assertEquals(['Alice', 'Bob'], $users);
}
public function testFetchUsersNotFound(): void
{
// Mock a 404 response
$mock = new MockHandler([
new Response(404, [], 'Not Found'),
]);
$handlerStack = HandlerStack::create($mock);
$client = new Client(['handler' => $handlerStack]);
$service = new MyService($client);
// Expect an exception or specific return based on your error handling
$this->expectException(\GuzzleHttp\Exception\ClientException::class); // If http_errors is true
$service->fetchUsers();
}
}
// Example MyService class
class MyService
{
private Client $client;
public function __construct(Client $client)
{
$this->client = $client;
}
public function fetchUsers(): array
{
$response = $this->client->get('users');
return json_decode($response->getBody()->getContents(), true)['users'];
}
}
❌ BAD: Hitting Real APIs in Tests
<?php
use GuzzleHttp\Client;
use PHPUnit\Framework\TestCase;
final class MyServiceIntegrationTest extends TestCase
{
public function testFetchUsersFromRealApi(): void
{
// ❌ BAD: Hits a real external API, making tests slow, flaky, and dependent on external services
$client = new Client(['base_uri' => 'https://api.example.com/v1/']);
$service = new MyService($client);
$users = $service->fetchUsers();
$this->assertIsArray($users);
$this->assertNotEmpty($users);
}
}
6. Laravel HTTP Client
If you are in a Laravel project, use Laravel's built-in HTTP client wrapper. It's powered by Guzzle but provides a more fluent API, built-in faking, and concurrent request helpers.
✅ GOOD: Laravel HTTP Client
<?php
use Illuminate\Support\Facades\Http;
final class LaravelIntegrationService
{
public function getUserData(int $userId): array
{
$response = Http::withToken('your_api_token')
->timeout(10)
->retry(3, 100) // Retry 3 times, 100ms delay
->get("https://api.example.com/users/{$userId}");
$response->throw(); // Throws an exception for 4xx/5xx responses
return $response->json();
}
public function sendConcurrentRequests(): array
{
$responses = Http::pool(fn (Http\Client\Pool $pool) => [
$pool->get('https://api.example.com/data/1'),
$pool->get('https://api.example.com/data/2'),
]);
return [
$responses[0]->json(),
$responses[1]->json(),
];
}
}