Peticiones cURL en PHP: GET, POST, cabeceras y errores

Peticiones cURL en PHP: GET, POST, cabeceras y errores
Respuesta rápida

curl_init(), curl_setopt_array() con CURLOPT_RETURNTRANSFER y luego curl_exec(), cuyo retorno hay que comprobar siempre. Desde PHP 8.5, curl_close() está obsoleta: el handle es un objeto que se libera automáticamente. Un código HTTP 404 o 500 no es un error de cURL, hay que leer CURLINFO_RESPONSE_CODE aparte.

cURL es la forma más directa de llamar a una API desde PHP. La extensión está presente en casi todos los alojamientos y gestiona HTTP, las redirecciones, las cookies y TLS. Este tutorial recorre los casos habituales, GET, POST, cabeceras, cookies, token Bearer, con la gestión de errores que la mayoría de los ejemplos olvidan, y señala lo que ha cambiado en PHP 8.5.

Lo que ha cambiado: curl_close() está obsoleta

Desde PHP 8.0, curl_init() ya no devuelve un recurso sino un objeto CurlHandle, que se libera automáticamente cuando la variable sale de ámbito. curl_close() llevaba, por tanto, cinco años sin efecto. PHP 8.5 asume la situación y marca la función como obsoleta.

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

Medido en PHP 8.5.10 con cURL 8.14.1. En PHP 8.4.25, la misma línea no produce ningún aviso: el código sigue siendo válido en un alojamiento compartido que se haya quedado en 8.4, pero es mejor quitar esas llamadas ahora que tener que volver a ellas más adelante. Para liberar un handle antes del final del script, unset($ch) hace el trabajo.

Una petición GET

Los parámetros de la URL se construyen con http_build_query(), que codifica los valores. Concatenarlos a mano se rompe en cuanto un valor contiene un espacio, un & o un acento.

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 a true hace que curl_exec() devuelva la respuesta en lugar de escribirla en la salida estándar. Sin esa opción, la respuesta se imprime en mitad de tu página.

Comprobar siempre el retorno

Es el olvido más caro. curl_exec() devuelve false cuando falla la red, y el script sigue como si nada.

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

Un json_decode($resultat) más abajo devolvería null, y el error se manifestaría tres pantallas después. Dos líneas de comprobación bastan para que salte donde realmente ocurre.

Ojo: un código HTTP 404 o 500 no es un error de cURL. La petición ha llegado, el servidor ha respondido y curl_exec() devuelve el cuerpo de la página de error. Hay que leer el código de respuesta aparte, o activar CURLOPT_FAILONERROR para que cURL trate como fallos las respuestas 400 y superiores.

Una petición POST

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);

La forma de CURLOPT_POSTFIELDS decide el tipo de contenido que se envía, y es una fuente permanente de confusión:

  • una cadena (http_build_query()) sale como application/x-www-form-urlencoded, el formato de un formulario HTML clásico.
  • un array sale como multipart/form-data, con fronteras MIME. Es el formato de los envíos de archivos, y muchas API lo rechazan.

Para enviar JSON, hay que declarar el tipo de forma explícita:

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),
    ],
]);

Añadir cabeceras

Las cabeceras se pasan como un array de cadenas Nom: valeur, mediante 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);

Un User-Agent explícito evita bloqueos: muchos servidores rechazan el agente por defecto de cURL, o la ausencia de agente.

Autenticación con token Bearer

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,
]);

Un token no se escribe en el código fuente. Viene de una variable de entorno o de un almacén de secretos. Y solo viaja por HTTPS: en una URL http://, el token circula en claro.

Las cookies

CURLOPT_COOKIE envía cookies fijas. Para una sesión que debe conservar las cookies entre varias peticiones, hacen falta CURLOPT_COOKIEJAR y CURLOPT_COOKIEFILE, con un archivo temporal.

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, // escribe las cookies recibidas
    CURLOPT_COOKIEFILE     => $cookies, // las reenvía en la siguiente petición
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => http_build_query(['login' => $login, 'mdp' => $mdp]),
]);
curl_exec($ch);

// Segunda petición: las cookies de sesión se envían solas
curl_setopt($ch, CURLOPT_URL, 'https://example.com/mon-compte');
curl_setopt($ch, CURLOPT_POST, false);
$page = curl_exec($ch);

unlink($cookies);

Nunca desactives la verificación TLS

Ante un error de certificado, la respuesta que se encuentra en los foros es siempre la misma:

php
// No hagas esto
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);

Esas dos líneas eliminan la única protección contra la interceptación. Cualquier intermediario de la red puede entonces leer y modificar los intercambios, incluido el token de autenticación. El error casi siempre significa que el almacén de certificados de la máquina falta o está caducado. La corrección adecuada es apuntar curl.cainfo a un archivo de certificados actualizado en php.ini, o actualizar el paquete ca-certificates del sistema.

El esqueleto reutilizable

En lugar de copiar las mismas opciones por todas partes, una función pequeña concentra los ajustes seguros.

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       => '', // acepta gzip y 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),
    );
}

Llamada sobre la API REST de WordPress en local, esta función devuelve:

code
HTTP 200, 294919 octets, 1.580 s

Varias peticiones en paralelo

Diez llamadas secuenciales de 200 ms cuestan dos segundos. Las mismas en paralelo cuestan lo que la más lenta de las diez. curl_multi_* sirve exactamente para eso.

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); // evita consumir CPU en vacío
    }
} 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);

El curl_multi_select() es imprescindible: sin él, el bucle ocupa un núcleo al 100 % durante toda la duración de las peticiones.

Sobre tres endpoints de la API REST de WordPress en local, medido tres veces tras una vuelta de calentamiento:

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

La ganancia crece con el número de llamadas y con la latencia de cada una. Sobre tres peticiones locales ya rápidas, el paralelo divide el tiempo por dos, sobre diez llamadas a una API remota, la diferencia es mucho más clara.

Cuándo no usar cURL directamente

Para una llamada puntual, cURL hace el trabajo. En cuanto el proyecto encadena integraciones, una biblioteca cliente como Guzzle o un cliente PSR-18 aporta los reintentos, la gestión de flujos y los interceptores sin tener que reescribirlos. Y si la extensión cURL no está disponible, file_get_contents() con un contexto de stream saca del apuro en un GET sencillo, pero sin control fino de los tiempos de espera ni diagnóstico de error aprovechable.

cURL cubre los intercambios petición-respuesta. Cuando es el servidor el que debe enviar datos por su cuenta, hace falta otro transporte: consulta crear un servidor WebSocket en PHP. Para llamar a la API de un servicio de envío de correo, consulta enviar un e-mail con PHP. Y para trasladar la respuesta al navegador, pasar variables de PHP a JavaScript.

Consulta también la conexión a una base de datos en PHP y el hub de Desarrollo web.

Errores frecuentes

curl_close() obsoleta en PHP 8.5 Function curl_close() is deprecated since 8.5, as it has no effect since PHP 8.0. Ningún aviso en PHP 8.4.25. El handle es un objeto CurlHandle que se libera solo, usa unset() si hace falta.
Retorno de curl_exec() sin comprobar Si falla la red, la función devuelve false y el script continúa. Un json_decode(false) más adelante da null, y el error aparece en un sitio que no tiene nada que ver.n404 confundido con un error de cURL | Una respuesta 404 es un éxito para cURL. Lee CURLINFO_RESPONSE_CODE, o activa CURLOPT_FAILONERROR.
POSTFIELDS como array Un array produce multipart/form-data, una cadena produce application/x-www-form-urlencoded. Comprobado con CURLINFO_HEADER_OUT. Muchas API rechazan el primero.
CURLOPT_SSL_VERIFYPEER desactivado Elimina la única protección contra la interceptación. La causa real es un almacén de certificados ausente o caducado: corrige curl.cainfo o el paquete ca-certificates.ncurl_multi sin curl_multi_select | El bucle ocupa un núcleo al 100 % durante toda la duración de las peticiones.

APIcURLHTTPPHP

Damien Flandrin Desarrollador web desde 2010, creador de Gekkode y de Email Impact. Cada artículo se prueba en un proyecto real antes de publicarse. Contacto
Newsletter

Las nuevas pruebas, tutoriales y proyectos, por correo.

Pruebas reproducibles, código versionado, resultados fechados. Nunca spam.