Consuming REST APIs

JA JASWANT Updated 17 Oct 2026
9 min read ·Lesson 107 of 248
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 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 User-defined Functions Slicing Strings Function Arguments 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 AJAX PHP AJAX Database AJAX XML AJAX Live Search AJAX Poll AJAX with JSON Security Introduction Password Hashing SQL Injection Prevention XSS Prevention Garbage Collection Project: Simple Blog PHP What is OOP PHP Classes/Objects The $this Keyword Access Array Items Update Array Items Add Array Items Remove Array Items Sorting Arrays Multidimensional Arrays Array Destructuring Array Spread Operator Search in Arrays Merge Arrays PHP Abstract Classes PHP Interfaces Polymorphism PHP Traits PHP Static Methods PHP Static Properties Late Static Binding Magic Methods Object Cloning MySQL Database MySQL Connect MySQL Create DB MySQL Create Table MySQL Insert Data MySQL Get Last ID MySQL Insert Multiple MySQL Prepared MySQL Select Data MySQL Where Debugging PHP Project: Image Upload Gallery $_SESSION $_ENV PHP RegEx RegEx Patterns Heredoc and Nowdoc RegEx Modifiers preg_match preg_replace preg_split PHP RegEx Functions Nullable Types Mixed and Never Types Nullsafe Operator PHP Attributes PHP Fibers Readonly Classes Property Hooks (PHP 8.4) Asymmetric Visibility (PHP 8.4) Reflection API SPL Data Structures Composer PHP Form Handling PDO Transactions PDO Error Handling PHP XML Parsers PHP SimpleXML Parser PHP SimpleXML - Get PHP XML Expat Parser PHP DOM Parser PHP XMLReader PHP XMLWriter AJAX Intro PHP Class Constants Encryption with Sodium Recursive Functions Variable Functions Anonymous Functions Closures Arrow Functions First-class Callable Syntax PHP Arrays Indexed Arrays Associative Arrays Create Arrays Variables Scope Static Variables Variable Variables PHP Echo / Print PHP printf / sprintf PHP Data Types PHP Booleans PHP NULL PHP Strings String Functions array_map / array_filter / array_reduce Array Functions PHP Superglobals $GLOBALS What Is `$_SERVER` ? $_REQUEST $_POST $_GET $_FILES $_COOKIE PHP Type Juggling MySQL Order By MySQL Delete Data MySQL Update Data MySQL Limit Data MySQL Joins with PHP Pagination with PHP PDO Introduction PDO Connect PDO Prepared Statements PDO Fetch Modes PSR-4 Autoloading Installing Extensions (PECL / PIE) OPcache & Performance PHP-FPM Project: Contact Form with Email Project: Login & Registration System Project: CRUD App with MySQL Project: Shopping Cart with Sessions Project: REST API with PHP DateTime Class Timezones PHP Include Include vs Require PHP File Handling PHP File Open/Read PHP File Create/Write File Delete / Rename / Copy PHP Directories PHP CSV Files
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.