Calling an API from PHP means making an HTTP request and parsing the JSON that comes back. The built-in tools do this — file_get_contents for the simplest cases, cURL for everything else — and Guzzle makes it pleasant. This guide covers all three, plus the error handling that separates code that works from code that works reliably.
The simplest request#
<?php
$json = file_get_contents("https://api.example.com/products");
if ($json === false) {
exit("Request failed.");
}
$products = json_decode($json, true);
Fine for a quick script. Its limits appear quickly: no control over timeouts, no access to the status code, and it depends on a PHP setting that some hosts disable. For anything real, use cURL.
cURL: the standard approach#
<?php
function apiGet(string $url, array $headers = []): array
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_HTTPHEADER => array_merge(["Accept: application/json"], $headers),
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($body === false) {
throw new RuntimeException("Request failed: $error");
}
if ($status >= 400) {
throw new RuntimeException("API returned $status: " . substr($body, 0, 200));
}
$data = json_decode($body, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new RuntimeException("Invalid JSON: " . json_last_error_msg());
}
return $data;
}
Four checks in that function, and each one catches a real failure: the request itself failing, an error status, invalid JSON, and a timeout. Code without them fails in ways that are hard to diagnose.
Sending data#
<?php
function apiPost(string $url, array $payload, array $headers = []): array
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => array_merge([
"Content-Type: application/json",
"Accept: application/json",
], $headers),
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status >= 400) {
throw new RuntimeException("API returned $status");
}
return json_decode($body, true) ?? [];
}
$result = apiPost("https://api.example.com/orders", [
"product_id" => 42,
"quantity" => 2,
]);
For other methods, set CURLOPT_CUSTOMREQUEST to “PUT”, “PATCH” or “DELETE”.
Guzzle#
composer require guzzlehttp/guzzle
<?php
require "vendor/autoload.php";
use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;
$client = new Client([
"base_uri" => "https://api.example.com/",
"timeout" => 10,
"headers" => [
"Accept" => "application/json",
"Authorization" => "Bearer " . getenv("API_TOKEN"),
],
]);
try {
$response = $client->get("products", ["query" => ["page" => 1]]);
$products = json_decode((string) $response->getBody(), true);
} catch (RequestException $e) {
$status = $e->getResponse()?->getStatusCode();
error_log("API call failed with status $status");
$products = [];
}
Guzzle throws on error statuses by default, handles query strings and JSON bodies, and supports concurrent requests. For anything beyond a couple of calls it is worth the dependency.
Authentication#
<?php
// Bearer token
$headers = ["Authorization: Bearer " . getenv("API_TOKEN")];
// API key in a custom header
$headers = ["X-API-Key: " . getenv("API_KEY")];
// Basic authentication
curl_setopt($ch, CURLOPT_USERPWD, getenv("API_USER") . ":" . getenv("API_PASS"));
Retries and rate limits#
<?php
function apiGetWithRetry(string $url, int $attempts = 3): array
{
$delay = 1;
for ($i = 1; $i <= $attempts; $i++) {
try {
return apiGet($url);
} catch (RuntimeException $e) {
if ($i === $attempts) {
throw $e;
}
sleep($delay);
$delay *= 2; // back off
}
}
return [];
}
Doubling the delay each time is called exponential backoff. Retrying immediately in a tight loop makes an overloaded server worse and often gets you blocked.
Only retry on failures that might succeed later — timeouts, 429, 502, 503. A 400 or 401 will fail identically every time.
Caching#
<?php
function cachedGet(string $url, int $ttl = 300): array
{
$file = sys_get_temp_dir() . "/api_" . md5($url) . ".json";
if (file_exists($file) && (time() - filemtime($file)) < $ttl) {
return json_decode(file_get_contents($file), true);
}
$data = apiGet($url);
file_put_contents($file, json_encode($data));
return $data;
}
Most API data does not change every second. Caching for even five minutes can cut your request count enormously and makes your pages noticeably faster.
Serving your own API#
<?php
declare(strict_types=1);
header("Content-Type: application/json; charset=utf-8");
if ($_SERVER["REQUEST_METHOD"] !== "GET") {
http_response_code(405);
header("Allow: GET");
echo json_encode(["error" => "Method not allowed"]);
exit;
}
$id = filter_input(INPUT_GET, "id", FILTER_VALIDATE_INT);
if ($id === false || $id === null) {
http_response_code(400);
echo json_encode(["error" => "A numeric id is required"]);
exit;
}
$stmt = $pdo->prepare("SELECT id, name, price FROM products WHERE id = ?");
$stmt->execute([$id]);
$product = $stmt->fetch();
if (!$product) {
http_response_code(404);
echo json_encode(["error" => "Not found"]);
exit;
}
echo json_encode($product, JSON_UNESCAPED_UNICODE);
Three habits in that snippet: set the content type, use the right status code for each outcome, and return errors as JSON rather than HTML. A client parsing your response should never receive an HTML error page.
Questions people ask#
cURL or Guzzle?
cURL if you want no dependencies and are making a handful of calls. Guzzle once you have several endpoints, need concurrency, or want retries and middleware without writing them yourself.
Why does json_decode return null?
The response was not valid JSON — often an HTML error page. Check json_last_error_msg() and print the first 200 characters of the body; the cause is usually obvious immediately.
Should json_decode use true for associative arrays?
Passing true gives arrays, which most PHP code handles more naturally. Without it you get objects and use arrow syntax. Pick one and be consistent.
How do I debug a failing request?
Print the status code, the response body and the request headers. Then reproduce it with the curl command line — that separates “my PHP is wrong” from “the API is rejecting this”.
Where to go next#
- What is an API? — the concepts behind all of this.
- Handling errors and exceptions in PHP — the try/catch above, in depth.
- PHP security best practices — keys, headers and validation.