PHP cURL request: GET, POST, headers and error handling

PHP cURL request: GET, POST, headers and error handling
Quick answer

curl_init(), curl_setopt_array() with CURLOPT_RETURNTRANSFER, then curl_exec(), whose return value you must always test. Since PHP 8.5, curl_close() is deprecated: the handle is an object that is freed automatically. An HTTP 404 or 500 is not a cURL error, so read CURLINFO_RESPONSE_CODE separately.

cURL is the most direct way to call an API from PHP. The extension is available on almost every host, and it handles HTTP, redirects, cookies and TLS. This tutorial goes through the common cases, GET, POST, headers, cookies, Bearer token, with the error handling most examples leave out, and flags what changed in PHP 8.5.

What changed: curl_close() is deprecated

Since PHP 8.0, curl_init() no longer returns a resource but a CurlHandle object, freed automatically when the variable goes out of scope. curl_close() has therefore had no effect for five years. PHP 8.5 makes it official and deprecates the function.

php
$ch = curl_init();
var_dump(get_debug_type($ch));  // string(10) "CurlHandle"
var_dump(is_resource($ch));     // bool(false)

curl_close($ch);
code
Deprecated: Function curl_close() is deprecated since 8.5,
as it has no effect since PHP 8.0

Measured on PHP 8.5.10 with cURL 8.14.1. On PHP 8.4.25 the same line produces no notice: the code is still valid on shared hosting that stayed on 8.4, but removing those calls now beats coming back to them later. To free a handle before the end of the script, unset($ch) does the job.

A GET request

URL parameters are built with http_build_query(), which encodes the values. Concatenating them by hand breaks as soon as a value contains a space, an & or an accented character.

src/get.php
<?php

declare(strict_types=1);

$parametres = ['recherche' => 'café & thé', 'page' => 2];
$url = 'https://api.example.com/articles?' . http_build_query($parametres);

$ch = curl_init();
curl_setopt_array($ch, [
    CURLOPT_URL            => $url,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CONNECTTIMEOUT => 5,
    CURLOPT_TIMEOUT        => 15,
    CURLOPT_FOLLOWLOCATION => true,
    CURLOPT_MAXREDIRS      => 3,
]);

$corps = curl_exec($ch);

if ($corps === false) {
    throw new RuntimeException('cURL : ' . curl_error($ch), curl_errno($ch));
}

$code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
echo "HTTP {$code}, " . strlen($corps) . " octets\n";

CURLOPT_RETURNTRANSFER set to true makes curl_exec() return the response instead of writing it to standard output. Without it, the response prints in the middle of your page.

Always check the return value

This is the most expensive omission of all. curl_exec() returns false on a network failure, and the script carries on as if nothing had happened.

php
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'http://hote-qui-nexiste-pas.invalid/');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
$resultat = curl_exec($ch);

var_dump($resultat);        // bool(false)
echo curl_errno($ch), "\n"; // 6
echo curl_error($ch), "\n"; // Could not resolve host: hote-qui-nexiste-pas.invalid

A json_decode($resultat) further down would return null, and the error would surface three screens later. Two lines of checking are enough to report it where it actually happens.

Careful: an HTTP 404 or 500 is not a cURL error. The request went through, the server answered, and curl_exec() returns the body of the error page. You have to read the response code separately, or turn on CURLOPT_FAILONERROR so cURL treats responses of 400 and above as failures.

A POST request

src/post.php
$donnees = ['nom' => 'Damien', 'message' => 'Bonjour & merci'];

$ch = curl_init();
curl_setopt_array($ch, [
    CURLOPT_URL            => 'https://api.example.com/contact',
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => http_build_query($donnees),
    CURLOPT_TIMEOUT        => 15,
]);

$reponse = curl_exec($ch);

The shape of CURLOPT_POSTFIELDS decides which content type is sent, and it is a permanent source of confusion:

  • a string (http_build_query()) goes out as application/x-www-form-urlencoded, the format of a plain HTML form.
  • an array goes out as multipart/form-data, with MIME boundaries. That is the format used for file uploads, and plenty of APIs reject it.

To send JSON, the type has to be declared explicitly:

php
$charge = json_encode(['nom' => 'Damien'], JSON_THROW_ON_ERROR);

curl_setopt_array($ch, [
    CURLOPT_POST       => true,
    CURLOPT_POSTFIELDS => $charge,
    CURLOPT_HTTPHEADER => [
        'Content-Type: application/json',
        'Accept: application/json',
        'Content-Length: ' . strlen($charge),
    ],
]);

Adding headers

Headers are passed as an array of Name: value strings, through CURLOPT_HTTPHEADER.

src/entetes.php
$ch = curl_init();
curl_setopt_array($ch, [
    CURLOPT_URL            => 'https://api.example.com/profil',
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => [
        'X-Custom-Header: MaValeur',
        'Accept: application/json',
        'User-Agent: Gekkode/1.0 (+https://www.gekkode.com)',
    ],
]);

$reponse = curl_exec($ch);

An explicit User-Agent avoids being blocked: many servers reject cURL’s default agent, or the absence of any agent.

Bearer token authentication

php
$jeton = getenv('API_TOKEN') ?: throw new RuntimeException('API_TOKEN manquant');

curl_setopt_array($ch, [
    CURLOPT_URL            => 'https://api.example.com/data',
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => ['Authorization: Bearer ' . $jeton],
    CURLOPT_SSL_VERIFYPEER => true,
    CURLOPT_SSL_VERIFYHOST => 2,
]);

A token is never written into the source code. It comes from an environment variable or a vault. And it only travels over HTTPS: on an http:// URL, the token goes across in clear text.

Cookies

CURLOPT_COOKIE sends fixed cookies. For a session that has to keep its cookies across several requests, you need CURLOPT_COOKIEJAR and CURLOPT_COOKIEFILE, with a temporary file.

src/session.php
$cookies = tempnam(sys_get_temp_dir(), 'ck');

$ch = curl_init();
curl_setopt_array($ch, [
    CURLOPT_URL            => 'https://example.com/connexion',
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_COOKIEJAR      => $cookies, // writes the cookies it receives
    CURLOPT_COOKIEFILE     => $cookies, // sends them back on the next request
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => http_build_query(['login' => $login, 'mdp' => $mdp]),
]);
curl_exec($ch);

// Second request: the session cookies go out on their own
curl_setopt($ch, CURLOPT_URL, 'https://example.com/mon-compte');
curl_setopt($ch, CURLOPT_POST, false);
$page = curl_exec($ch);

unlink($cookies);

Never disable TLS verification

Faced with a certificate error, the answer found on forums is always the same:

php
// What not to do
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);

Those two lines remove the only protection against interception. Any intermediary on the network can then read and alter the exchange, authentication token included. The error nearly always means the machine’s certificate store is missing or out of date. The right fix is to point curl.cainfo at an up-to-date certificate bundle in php.ini, or to update the system’s ca-certificates package.

The reusable skeleton

Rather than copying the same options everywhere, one small function holds the safe settings.

src/Http.php
<?php

declare(strict_types=1);

final class ReponseHttp
{
    public function __construct(
        public readonly int $code,
        public readonly string $corps,
        public readonly float $duree,
    ) {}

    public function json(): array
    {
        return json_decode($this->corps, true, 512, JSON_THROW_ON_ERROR);
    }
}

function appel(
    string $url,
    string $methode = 'GET',
    ?string $corps = null,
    array $entetes = [],
    int $delai = 15,
): ReponseHttp {
    $ch = curl_init();
    curl_setopt_array($ch, [
        CURLOPT_URL            => $url,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CUSTOMREQUEST  => $methode,
        CURLOPT_CONNECTTIMEOUT => 5,
        CURLOPT_TIMEOUT        => $delai,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_MAXREDIRS      => 3,
        CURLOPT_SSL_VERIFYPEER => true,
        CURLOPT_SSL_VERIFYHOST => 2,
        CURLOPT_HTTPHEADER     => $entetes,
        CURLOPT_ENCODING       => '', // accept gzip and deflate
    ]);

    if ($corps !== null) {
        curl_setopt($ch, CURLOPT_POSTFIELDS, $corps);
    }

    $reponse = curl_exec($ch);

    if ($reponse === false) {
        throw new RuntimeException(
            sprintf('cURL %d sur %s : %s', curl_errno($ch), $url, curl_error($ch)),
            curl_errno($ch),
        );
    }

    return new ReponseHttp(
        code:  curl_getinfo($ch, CURLINFO_RESPONSE_CODE),
        corps: $reponse,
        duree: curl_getinfo($ch, CURLINFO_TOTAL_TIME),
    );
}

Called against a local WordPress REST API, this function gives:

code
HTTP 200, 294919 octets, 1.580 s

Several requests in parallel

Ten sequential calls of 200 ms cost two seconds. The same ten in parallel cost whatever the slowest one costs. curl_multi_* exists for exactly that.

src/parallele.php
$urls = [
    'https://api.example.com/a',
    'https://api.example.com/b',
    'https://api.example.com/c',
];

$multi   = curl_multi_init();
$handles = [];

foreach ($urls as $i => $url) {
    $ch = curl_init();
    curl_setopt_array($ch, [
        CURLOPT_URL            => $url,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 15,
    ]);
    curl_multi_add_handle($multi, $ch);
    $handles[$i] = $ch;
}

do {
    $etat = curl_multi_exec($multi, $encoursDExecution);
    if ($encoursDExecution) {
        curl_multi_select($multi); // avoids spinning the CPU for nothing
    }
} while ($encoursDExecution && $etat === CURLM_OK);

$reponses = [];
foreach ($handles as $i => $ch) {
    $reponses[$i] = curl_multi_getcontent($ch);
    curl_multi_remove_handle($multi, $ch);
}
curl_multi_close($multi);

The curl_multi_select() call is essential: without it, the loop pins a core at 100% for as long as the requests last.

Across three endpoints of a local WordPress REST API, measured three times after a warm-up round:

code
passe 1 : séquentiel  1367 ms | parallèle  459 ms
passe 2 : séquentiel   868 ms | parallèle  415 ms
passe 3 : séquentiel   733 ms | parallèle  408 ms

The gain grows with the number of calls and with the latency of each one. On three local requests that are already fast, going parallel halves the time, on ten calls to a remote API, the gap gets far wider.

When not to use cURL directly

For a one-off call, cURL does the job. As soon as a project starts stacking up integrations, a client library such as Guzzle or a PSR-18 client brings retries, stream handling and interceptors without you rewriting them. And if the cURL extension is not available, file_get_contents() with a stream context will do for a simple GET, but with no fine-grained timeouts and no usable error diagnostics.

cURL covers request-response exchanges. When the server has to push data of its own accord, you need another transport: see creating a WebSocket server in PHP. To call the API of an email sending service, see sending an email with PHP. And to hand the response on to the browser, passing variables from PHP to JavaScript.

See also connecting to a database in PHP and the web development hub.

Common errors

curl_close() deprecated in PHP 8.5 Function curl_close() is deprecated since 8.5, as it has no effect since PHP 8.0. No notice on PHP 8.4.25. The handle is a CurlHandle object that frees itself, use unset() if you really need to.
Return value of curl_exec() left untested On a network failure the function returns false and the script carries on. A json_decode(false) further down gives null, and the error surfaces somewhere completely unrelated.n404 mistaken for a cURL error | A 404 response is a success as far as cURL is concerned. Read CURLINFO_RESPONSE_CODE, or turn on CURLOPT_FAILONERROR.
POSTFIELDS passed as an array An array produces multipart/form-data, a string produces application/x-www-form-urlencoded. Checked with CURLINFO_HEADER_OUT. Plenty of APIs reject the former.
CURLOPT_SSL_VERIFYPEER disabled Removes the only protection against interception. The real cause is a missing or out-of-date certificate store: fix curl.cainfo or the ca-certificates package.ncurl_multi without curl_multi_select | The loop pins a core at 100% for as long as the requests last.

APIcURLHTTPPHP

Damien Flandrin Web developer since 2010, creator of Gekkode and Email Impact. Every article is tested on a real project before publication. Contact
Newsletter

New tests, tutorials and projects, by e-mail.

Reproducible tests, versioned code, dated results. Never any spam.