Consuming REST APIs
What Does "Consuming an API" Mean? In simple terms: Consuming an API means your application uses another service's features over HTTP — sending an SMS, charging a card, checking shipment status, or fe
What Does "Consuming an API" Mean?
In simple terms: Consuming an API means your application uses another service's features over HTTP — sending an SMS, charging a card, checking shipment status, or fetching weather data. Your PHP code sends requests and processes the JSON responses.
Technically: A REST API exposes resources (users, orders, payments) at URLs and uses HTTP methods to act on them. Responses carry status codes and usually JSON bodies. PHP cURL showed how to send individual requests; this lesson shows how to build a reliable API client: authentication, URL building, status-code handling, retries with backoff, rate limits, pagination, idempotency, and testing.
REST Basics
| Method | Purpose | Example | Typical Success Status |
|---|---|---|---|
GET | Read | GET /users/7 | 200 OK |
POST | Create | POST /orders | 201 Created |
PUT | Replace | PUT /users/7 | 200 OK |
PATCH | Partially update | PATCH /users/7 | 200 OK |
DELETE | Delete | DELETE /orders/501 | 204 No Content |
Status Code Classes
| Range | Meaning | What Your Client Should Do |
|---|---|---|
2xx | Success | Use the response |
4xx | Client error (your request is wrong) | Do not retry; fix the request or report the problem (401 → check credentials, 404 → not found, 422 → validation errors) |
429 | Too many requests | Wait (respect Retry-After) and retry |
5xx | Server error | Retry a limited number of times with increasing delays |
Retries With Exponential Backoff
When a service is temporarily overloaded, retrying immediately makes it worse. Exponential backoff doubles the waiting time after each failed attempt, up to a maximum:
<?php
function backoffDelay(int $attempt, int $baseMs = 200, int $maxMs = 5000): int
{
return min($maxMs, $baseMs * 2 ** ($attempt - 1));
}
foreach (range(1, 6) as $attempt) {
echo "After attempt $attempt: wait " . backoffDelay($attempt) . " ms\n";
}
Output
After attempt 1: wait 200 ms
After attempt 2: wait 400 ms
After attempt 3: wait 800 ms
After attempt 4: wait 1600 ms
After attempt 5: wait 3200 ms
After attempt 6: wait 5000 ms
In production, add jitter — a small random amount (for example, random_int(0, 100) ms) — so that many clients failing at the same moment do not all retry at exactly the same time.
Building an API Client
A dedicated client class keeps all API details — base URL, authentication, error handling, retries — in one place. Application code then simply calls $client->get('/users/7').
To make the client testable, the actual HTTP transport and the sleep function are passed in as callables. In production they use cURL and usleep(); in tests they are replaced with fakes.
<?php
final class ApiException extends RuntimeException
{
public function __construct(string $message, public readonly int $status, public readonly array $body = [])
{
parent::__construct($message, $status);
}
}
final class ApiClient
{
public function __construct(
private string $baseUrl,
private string $token,
private Closure $transport, // fn(string $method, string $url, array $headers, ?string $body): array
private Closure $sleep, // fn(int $milliseconds, int $attempt, int $status): void
private int $maxAttempts = 4,
) {}
public function get(string $path, array $query = []): array
{
return $this->request('GET', $path, $query);
}
public function post(string $path, array $data, ?string $idempotencyKey = null): array
{
return $this->request('POST', $path, [], $data, $idempotencyKey);
}
private function request(string $method, string $path, array $query = [], ?array $json = null, ?string $idempotencyKey = null): array
{
$url = rtrim($this->baseUrl, '/') . '/' . ltrim($path, '/')
. ($query === [] ? '' : '?' . http_build_query($query));
$headers = ['Accept: application/json', 'Authorization: Bearer ' . $this->token];
if ($idempotencyKey !== null) {
$headers[] = 'Idempotency-Key: ' . $idempotencyKey;
}
$body = $json === null ? null : json_encode($json, JSON_THROW_ON_ERROR);
for ($attempt = 1; ; $attempt++) {
$response = ($this->transport)($method, $url, $headers, $body);
$status = $response['status'];
$retryable = $status === 429 || $status >= 500;
if ($retryable && $attempt < $this->maxAttempts) {
$delayMs = isset($response['headers']['retry-after'])
? (int) $response['headers']['retry-after'] * 1000
: backoffDelay($attempt);
($this->sleep)($delayMs, $attempt, $status);
continue;
}
$data = $response['body'] === '' ? [] : json_decode($response['body'], true, 64, JSON_THROW_ON_ERROR);
if ($status >= 200 && $status < 300) {
return $data;
}
throw new ApiException(
sprintf('%s %s failed with status %d: %s', $method, $path, $status, $data['message'] ?? 'Unknown error'),
$status,
is_array($data) ? $data : []
);
}
}
}
How the Client Works
- URL building: the base URL and path are joined with exactly one slash, and query parameters are encoded with
http_build_query(). - Authentication: every request carries an
Authorization: Bearer ...header. The token comes from configuration (an environment variable), never from code. - Retries:
429and5xxresponses are retried up to$maxAttemptstimes. The delay honors the server'sRetry-Afterheader when present; otherwise, exponential backoff is used. - Errors: non-retryable failures (and retryable ones after the last attempt) throw an
ApiExceptionthat carries the status and the decoded error body, so callers can react (for example, show validation errors from a422). - Idempotency:
POSTrequests can include anIdempotency-Key(supported by many payment APIs). If a retry repeats a request that actually succeeded, the server recognizes the key and does not create a second payment.
Testing the Client With a Fake Transport
<?php
$responses = [
['status' => 503, 'headers' => [], 'body' => '{"message":"Service unavailable"}'],
['status' => 429, 'headers' => ['retry-after' => '1'], 'body' => '{"message":"Slow down"}'],
['status' => 200, 'headers' => [], 'body' => '{"id":7,"name":"Asha"}'],
];
$transport = function (string $method, string $url, array $headers, ?string $body) use (&$responses): array {
return array_shift($responses);
};
$sleep = function (int $ms, int $attempt, int $status): void {
echo "Attempt $attempt: status $status, waiting $ms ms\n";
};
$client = new ApiClient('https://api.example.com/v1', 'test-token', $transport, $sleep);
$user = $client->get('/users/7');
echo "User: {$user['name']}\n";
$responses = [['status' => 404, 'headers' => [], 'body' => '{"message":"User not found"}']];
try {
$client->get('/users/99');
} catch (ApiException $e) {
echo $e->getMessage() . " (status {$e->status})\n";
}
Output
Attempt 1: status 503, waiting 200 ms
Attempt 2: status 429, waiting 1000 ms
User: Asha
GET /users/99 failed with status 404: User not found (status 404)
What the Test Shows
- The fake transport returns prepared responses in order: a server error, a rate limit, then success. The client retries twice and returns the user.
- The first wait uses backoff (200 ms); the second uses the server's
Retry-After: 1(1000 ms). - A
404is not retried — the resource does not exist, so retrying would never help. - No real network requests or real waiting happen, so the test is fast and repeatable.
Pagination
APIs return large collections in pages. A generator can hide the paging and provide all items as one stream:
<?php
function allItems(ApiClient $client, string $path): Generator
{
for ($page = 1; ; $page++) {
$data = $client->get($path, ['page' => $page, 'per_page' => 2]);
if ($data['items'] === []) {
return;
}
foreach ($data['items'] as $item) {
yield $item;
}
}
}
$pages = [1 => ['Asha', 'Bala'], 2 => ['Charu'], 3 => []];
$pagedTransport = function (string $method, string $url) use ($pages): array {
parse_str((string) parse_url($url, PHP_URL_QUERY), $query);
return ['status' => 200, 'headers' => [], 'body' => json_encode(['items' => $pages[(int) $query['page']]])];
};
$client = new ApiClient('https://api.example.com/v1', 'test-token', $pagedTransport, fn() => null);
foreach (allItems($client, '/customers') as $customer) {
echo $customer . "\n";
}
Output
Asha
Bala
Charu
The caller simply loops over customers; the generator requests page after page until an empty page arrives. Because items are yielded one at a time, even very large collections use little memory. Other APIs use a "next page" URL or a cursor token instead of page numbers; the same pattern applies.
Using the Client With cURL in Production
<?php
$curlTransport = function (string $method, string $url, array $headers, ?string $body): array {
$ch = curl_init($url);
$responseHeaders = [];
$options = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 20,
CURLOPT_HEADERFUNCTION => function ($handle, string $line) use (&$responseHeaders): int {
if (str_contains($line, ':')) {
[$name, $value] = explode(':', $line, 2);
$responseHeaders[strtolower(trim($name))] = trim($value);
}
return strlen($line);
},
];
if ($body !== null) {
$options[CURLOPT_POSTFIELDS] = $body;
$options[CURLOPT_HTTPHEADER][] = 'Content-Type: application/json';
}
curl_setopt_array($ch, $options);
$responseBody = curl_exec($ch);
if ($responseBody === false) {
// Treat network failures like a temporary server error, so they are retried
return ['status' => 503, 'headers' => [], 'body' => ''];
}
return ['status' => curl_getinfo($ch, CURLINFO_RESPONSE_CODE), 'headers' => $responseHeaders, 'body' => $responseBody];
};
$client = new ApiClient(
getenv('SHIPPING_API_URL'),
getenv('SHIPPING_API_TOKEN'),
$curlTransport,
fn(int $ms) => usleep($ms * 1000),
);
The client class is unchanged; only the transport differs between production and tests. A request body is attached only when there is one (setting CURLOPT_POSTFIELDS would otherwise turn the request into a POST). Network failures are mapped to a 503 so they benefit from the same retry logic.
Alternatives
HTTP client libraries such as Guzzle and Symfony HttpClient provide retries, middleware, mocking, and parallel requests out of the box. Many API providers also publish official PHP SDKs (for example, for payment gateways and cloud services) — prefer them when available, as they handle authentication and API changes for you.
Practical Concerns
| Concern | Recommendation |
|---|---|
| Secrets | Keep API keys in environment variables; never log them |
| Timeouts | Always set connection and total timeouts |
| Rate limits | Respect 429 and Retry-After; cache responses that do not change often |
| Idempotency | Use idempotency keys for operations that must not happen twice (payments, orders) |
| Logging | Log method, path, status, duration, and request IDs — not tokens or personal data |
| Webhooks | Many APIs also call your server; verify their signatures before trusting the data |
| Background work | Slow or non-urgent API calls belong in queued background jobs |
Common Mistakes
Retrying Client Errors
Effect: Invalid requests are repeated pointlessly, sometimes triggering rate limits.
Fix: Retry only 429, 5xx, and network failures.
Retrying Without Delays
Effect: Overloaded services are overwhelmed further.
Fix: Use exponential backoff with jitter and respect Retry-After.
Retrying Non-Idempotent Requests Without Keys
Effect: Duplicate orders or payments.
Fix: Send idempotency keys, or do not retry such requests automatically.
Trusting Response Structure Blindly
Effect: Missing fields cause errors deep in your code when the API changes.
Fix: Validate the fields you use, and handle absent data explicitly.
Hard-Wiring cURL Into Business Logic
Effect: Code cannot be tested without real network calls.
Fix: Wrap API access in a client class with an injectable transport.
Best Practices
- Encapsulate each external API in a client class with clear methods.
- Handle 2xx, 4xx, 429, and 5xx differently, and throw meaningful exceptions.
- Retry temporary failures with backoff, jitter, and idempotency keys.
- Inject the transport so the client can be tested with fake responses.
- Keep credentials in configuration, set timeouts, and log calls without sensitive data.
Official reference: https://www.php.net/manual/en/book.curl.php