PHPConsuming APIs (cURL & HTTP)

Consuming APIs (cURL & HTTP)

Most non-trivial applications eventually need to talk to some other service over HTTP — checking an exchange rate, verifying a payment, sending a message through a third-party provider, looking up a shipping rate. PHP's built-in way of doing this is the cURL extension, a thin wrapper around the widely used libcurl library that gives fine-grained control over an HTTP request: which method to use, which headers to send, how to authenticate, and how to handle timeouts and errors. It's verbose compared to HTTP clients in some other languages, but understanding the raw cURL pattern makes it much easier to reason about what a higher-level library like Guzzle is actually doing underneath.

The curl_init / curl_setopt / curl_exec pattern

Every cURL request in PHP follows the same three-step shape: curl_init() creates a request "handle" (a resource representing the request being built), curl_setopt() is called once per option to configure that handle — the URL, the method, headers, and so on — and curl_exec() actually sends the request and returns the response body. A final curl_close() releases the handle once it's no longer needed.

A basic GET request

PHP
<?php
$ch = curl_init('https://api.example.com/status');

curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);

$response = curl_exec($ch);
curl_close($ch);

echo $response;

CURLOPT_RETURNTRANSFER is easy to forget but changes the behavior completely: without it, curl_exec() prints the response body directly and returns true/false instead of giving the body back as a string, which is almost never what a script actually wants. CURLOPT_TIMEOUT caps how long PHP will wait for the whole request to complete — without a timeout, a script can hang indefinitely if the remote API stalls, which is a real production risk for anything handling incoming user requests on a web server.

Sending headers and an auth token

Most real APIs require an Authorization header carrying an API key or bearer token, and often expect a specific Content-Type for the request body. CURLOPT_HTTPHEADER takes an array of raw header strings, and CURLOPT_POSTFIELDS supplies the request body for a POST, PUT, or PATCH request. Setting CURLOPT_CUSTOMREQUEST is how the HTTP method itself is changed away from the default GET.

A POST request with a bearer token and a JSON body

PHP
<?php
$payload = json_encode([
    'amount' => 2500,
    'currency' => 'usd',
]);

$ch = curl_init('https://api.example.com/v1/charges');

curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Authorization: Bearer ' . getenv('API_TOKEN'),
    'Content-Type: application/json',
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);

$response = curl_exec($ch);
$statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

echo "Status: {$statusCode}\n";
echo $response;
Status: 201
{"id":"ch_1a2b3c","amount":2500,"currency":"usd","status":"succeeded"}
Decoding the response and handling errors

A successful response body is usually a JSON string that still needs json_decode() to become a usable PHP array or object. But curl_exec() returning a string doesn't necessarily mean the request succeeded — a curl_exec() return value of false indicates a transport-level failure (DNS resolution failed, the connection timed out, the remote host refused the connection), which is completely different from an HTTP-level failure like a 404 or 500 response, which cURL treats as a perfectly normal completed request. Checking both layers separately is the difference between an API client that fails silently and one that reports useful errors.

Checking curl_errno() and the HTTP status separately

PHP
<?php
$ch = curl_init('https://api.example.com/v1/charges/ch_1a2b3c');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);

$response = curl_exec($ch);

if (curl_errno($ch)) {
    // Transport-level failure: DNS, connection refused, timeout, etc.
    $error = curl_error($ch);
    curl_close($ch);
    throw new RuntimeException("Request failed: {$error}");
}

$statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($statusCode >= 400) {
    // The request reached the server, but it rejected it.
    throw new RuntimeException("API returned HTTP {$statusCode}: {$response}");
}

$data = json_decode($response, true);
echo $data['status'];
curl_exec() returning a string is not the same as success
It's tempting to treat any non-`false` return from `curl_exec()` as "it worked," but a `404 Not Found` or `500 Internal Server Error` response is still a string, and cURL happily returns it without raising any error of its own. Always inspect `curl_getinfo($ch, CURLINFO_HTTP_CODE)` alongside `curl_errno($ch)` — the first catches HTTP-level failures, the second catches transport-level ones, and neither one substitutes for the other.
Guzzle: the ecosystem-standard HTTP client

Raw cURL works, but the option-setting boilerplate above gets repetitive fast across a real application with many outgoing API calls. Guzzle is the HTTP client the PHP ecosystem has broadly standardized on for this — it wraps the same underlying transport cURL uses, but exposes a far more ergonomic API: JSON bodies, headers, and query parameters are passed as plain array options, responses come back as proper objects with helper methods, and it integrates with the PSR-7 HTTP message interfaces so it plays nicely with any other PSR-7-aware library or framework middleware.

The same POST request, using Guzzle

PHP
<?php
require __DIR__ . '/vendor/autoload.php';

use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;

$client = new Client(['timeout' => 10]);

try {
    $response = $client->post('https://api.example.com/v1/charges', [
        'headers' => [
            'Authorization' => 'Bearer ' . getenv('API_TOKEN'),
        ],
        'json' => [
            'amount' => 2500,
            'currency' => 'usd',
        ],
    ]);

    $data = json_decode($response->getBody()->getContents(), true);
    echo $data['status'];
} catch (RequestException $e) {
    error_log('Charge request failed: ' . $e->getMessage());
}

Guzzle also throws an exception by default on a 4xx or 5xx response, which removes the need to manually branch on the status code for the common case — errors surface as exceptions the same way any other PHP failure would, which fits much more naturally into normal try/catch error handling than manually checking a status code after every call.

Guzzle depends on Composer
Guzzle is installed with `composer require guzzlehttp/guzzle` and, like any Composer package, is only usable once `vendor/autoload.php` has been required. Raw cURL, by contrast, needs no dependency at all — it's a PHP extension enabled by default in most installations — which is why it's still the right tool for a quick script that makes one or two outgoing requests and doesn't want a Composer dependency just for that.
  • Every raw cURL request follows the same pattern: curl_init(), one or more curl_setopt() calls, curl_exec(), then curl_close().

  • CURLOPT_RETURNTRANSFER must be set to true to get the response body back as a string instead of it being printed directly.

  • CURLOPT_HTTPHEADER sends custom headers like Authorization: Bearer ...; CURLOPT_POSTFIELDS sends the request body.

  • curl_errno() catches transport-level failures (DNS, timeout, refused connection); CURLINFO_HTTP_CODE via curl_getinfo() catches HTTP-level failures like 404 or 500 — check both.

  • Guzzle wraps the same underlying transport with a far more ergonomic API, PSR-7 integration, and exceptions thrown automatically on error responses.

Tip
Always set an explicit `CURLOPT_TIMEOUT` (or Guzzle's `timeout` option) on any outgoing request made from within a web request handler. Without one, a slow or hanging third-party API can tie up a PHP worker process indefinitely, which under enough concurrent traffic can exhaust all available workers and take the whole application down — not just the one feature that depends on that API.