Consuming REST APIs

JA JASWANT Updated 17 Oct 2026
9 min read ·Lesson 108 of 109
In this post

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

MethodPurposeExampleTypical Success Status
GETReadGET /users/7200 OK
POSTCreatePOST /orders201 Created
PUTReplacePUT /users/7200 OK
PATCHPartially updatePATCH /users/7200 OK
DELETEDeleteDELETE /orders/501204 No Content

Status Code Classes

RangeMeaningWhat Your Client Should Do
2xxSuccessUse the response
4xxClient error (your request is wrong)Do not retry; fix the request or report the problem (401 → check credentials, 404 → not found, 422 → validation errors)
429Too many requestsWait (respect Retry-After) and retry
5xxServer errorRetry 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

  1. URL building: the base URL and path are joined with exactly one slash, and query parameters are encoded with http_build_query().
  2. Authentication: every request carries an Authorization: Bearer ... header. The token comes from configuration (an environment variable), never from code.
  3. Retries: 429 and 5xx responses are retried up to $maxAttempts times. The delay honors the server's Retry-After header when present; otherwise, exponential backoff is used.
  4. Errors: non-retryable failures (and retryable ones after the last attempt) throw an ApiException that carries the status and the decoded error body, so callers can react (for example, show validation errors from a 422).
  5. Idempotency: POST requests can include an Idempotency-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 404 is 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

ConcernRecommendation
SecretsKeep API keys in environment variables; never log them
TimeoutsAlways set connection and total timeouts
Rate limitsRespect 429 and Retry-After; cache responses that do not change often
IdempotencyUse idempotency keys for operations that must not happen twice (payments, orders)
LoggingLog method, path, status, duration, and request IDs — not tokens or personal data
WebhooksMany APIs also call your server; verify their signatures before trusting the data
Background workSlow 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

0 Comments

Reviewed before they appear

No comments yet.

PHP
PHP Home PHP Switch PHP Match Alternative Syntax PHP Loops While Loop Do While Loop For Loop Foreach Loop Nested Loops Break Statement PHP Home PHP Intro PHP Install PHP Built-in Web Server PHP Syntax PHP Tags PHP and HTML PHP Comments PHP Multiline Comments PHP Variables Variables Scope PHP Casting Logical Operators Null Coalescing Operator Spaceship Operator Operator Precedence Continue Statement Modify Strings PHP If...Else...Elseif PHP If Modify Strings PHP If Operators PHP Functions Concatenate Strings PHP If...Else Slicing Strings User-defined Functions Slicing Strings PHP Shorthand if Escape Characters Default Argument Values PHP Nested if Pass by Reference String Interpolation Named Arguments PHP File Upload Variadic Functions Return Values Search in Strings Multibyte Strings Type Declarations in Functions PHP Numbers PHP Math PHP Floats PHP Random Numbers PHP Constants PHP Properties PHP Methods PHP Constructor Constructor Property Promotion PHP Cookies PHP Destructor PHP Access Modifiers Readonly Properties PHP Sessions PHP Inheritance Method Overriding PHP Filters The final Keyword CSRF Protection PHP Filters Advanced Session Security Secure Cookies PHP Magic Constants File Upload Security PHP Callback Functions PHP Operators Filesystem Security PHP JSON Arithmetic Operators Error Reporting in Production Hiding PHP Assignment Operators PHP Configuration (php.ini) Comparison Operators PHP Command Line (CLI) PHP Exceptions Increment / Decrement Operators Migrating to PHP 8 PHP Form Validation PHP Form Required PHP Form URL/E-mail Error Handling Form Input Sanitizing Escaping Output (htmlspecialchars) Custom Error Handler Checkbox, Radio and Select Inputs Sticky Forms PHP Form Complete PHP Date and Time Date Formatting Error Logging HTTP Headers and Redirects Output Buffering Sending Email PHP cURL Consuming REST APIs PHP Generators
Ask about this post
AI Ask about this post

Ask questions about Consuming REST APIs and get answers drawn from it.

Signed-in readers only.