Die Anfrage läuft, aber in der Variablen steht nur true. Oder false, ohne jeden Hinweis warum. Oder die Antwort ist da, stammt aber von einer Fehlerseite, die das Skript trotzdem weiterverarbeitet. Fast alle Probleme mit PHP curl_exec gehen auf dieselbe Frage zurück: was gibt diese Funktion eigentlich zurück?
Diese Frage klären wir zuerst, weil alles Weitere darauf aufbaut.
Was PHP curl_exec zurückgibt
Die Funktion kennt drei mögliche Rückgabewerte, und welcher davon kommt, hängt an einer einzigen Option.
| Rückgabe | Wann | Was das heisst |
| String | CURLOPT_RETURNTRANSFER ist gesetzt | Der Antwortkörper. Kann auch leer sein, das ist kein Fehler. |
true | Die Option fehlt | Die Antwort wurde direkt ausgegeben, mitten in deine Seite. |
false | Transportfehler | DNS, Verbindung, TLS oder Timeout. Details liefert curl_error(). |
<?php
$ch = curl_init('https://example.org/api');
/* Ohne RETURNTRANSFER: Ausgabe geht direkt raus, Rueckgabe ist true */
$a = curl_exec($ch);
var_dump($a); /* bool(true) */
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
/* Mit RETURNTRANSFER: die Antwort kommt als String zurueck */
$b = curl_exec($ch);
var_dump(is_string($b)); /* bool(true) */
Ein Detail aus der Tabelle wird gern übersehen: ein leerer String ist ein gültiges Ergebnis. Eine Prüfung mit if (!$antwort) behandelt ihn wie einen Fehler, weil ein leerer String in PHP als unwahr gilt. Prüfe deshalb immer mit === false.
Fehler auf zwei Ebenen prüfen
Das ist der wichtigste Abschnitt dieses Tutorials. Eine cURL-Anfrage kann auf zwei völlig verschiedene Arten schiefgehen, und beide brauchen eine eigene Prüfung.
Die erste Ebene ist der Transport. Der Server ist nicht erreichbar, der DNS-Name löst nicht auf, das Zertifikat wird abgelehnt, die Zeit läuft ab. In diesen Fällen liefert PHP curl_exec false.
Die zweite Ebene ist die HTTP-Antwort. Der Server hat geantwortet, aber mit 404 oder 500. Aus Sicht von cURL ist das ein voller Erfolg: die Anfrage kam an, eine Antwort kam zurück. PHP curl_exec liefert dann einen String, und wer nur auf false prüft, verarbeitet ahnungslos den Inhalt einer Fehlerseite weiter.
<?php
$ch = curl_init('https://example.org/api/artikel/42');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 15,
]);
$antwort = curl_exec($ch);
/* Stufe 1: kam die Anfrage ueberhaupt durch? */
if ($antwort === false) {
throw new RuntimeException(
'Transportfehler ' . curl_errno($ch) . ': ' . curl_error($ch)
);
}
/* Stufe 2: was hat der Server geantwortet? */
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status < 200 || $status >= 300) {
throw new RuntimeException('HTTP ' . $status . ' vom Server.');
}
echo $antwort;
Dieses Muster taucht im weiteren Verlauf immer wieder auf. Der numerische Code aus curl_errno() ist dabei nützlicher als die Textmeldung, weil sich darauf gezielt reagieren lässt: 6 steht für einen nicht auflösbaren Host, 28 für einen Timeout, 60 für ein abgelehntes Zertifikat. Die Tutorials zu PHP curl_error() und PHP curl_getinfo() gehen auf beide Bausteine im Detail ein.
Timeouts setzen, damit nichts hängen bleibt
Ohne Zeitgrenze wartet PHP curl_exec, bis das Gegenüber antwortet oder die Verbindung von sich aus abbricht. Bei einem überlasteten Fremddienst steht solange deine komplette Seite. Zwei Optionen begrenzen das, und sie meinen Verschiedenes.
<?php
curl_setopt_array($ch, [
/* Nur der Verbindungsaufbau, inklusive DNS und TLS-Handshake */
CURLOPT_CONNECTTIMEOUT => 5,
/* Die gesamte Anfrage, vom Start bis zum letzten Byte */
CURLOPT_TIMEOUT => 15,
]);
CURLOPT_CONNECTTIMEOUT darf knapp sein. Wer nach fünf Sekunden keine Verbindung aufgebaut hat, schafft es meist auch in dreissig nicht. CURLOPT_TIMEOUT richtet sich danach, wie viel Daten erwartet werden: für eine JSON-Antwort reichen zehn bis fünfzehn Sekunden, für einen grossen Download darf der Wert deutlich höher liegen. Wichtig ist nur, dass beide gesetzt sind.
POST-Anfragen mit JSON senden
Der häufigste Praxisfall bei APIs. Entscheidend ist, dass der Body ein String ist und der passende Content-Type mitgeschickt wird.
<?php
$daten = ['name' => 'Kaffeemuehle', 'preis' => 49.90];
$body = json_encode($daten, JSON_THROW_ON_ERROR);
$ch = curl_init('https://example.org/api/artikel');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Content-Length: ' . strlen($body),
'Accept: application/json',
],
CURLOPT_TIMEOUT => 15,
]);
$antwort = curl_exec($ch);
Übergibst du CURLOPT_POSTFIELDS stattdessen ein Array, baut cURL daraus automatisch eine multipart/form-data-Anfrage und setzt den Content-Type selbst. Das ist richtig für Datei-Uploads und für klassische Formulare, aber falsch für eine JSON-Schnittstelle. Der Endpunkt bekommt dann einen Body, den er nicht parsen kann, und antwortet meist mit 400. Wie eine solche Schnittstelle auf der Gegenseite aussieht, zeigt das Tutorial zu PHP REST API erstellen.
Der folgende Ablauf fasst die zweistufige Auswertung zusammen.
flowchart TD
A[curl_exec aufrufen] --> B{Rueckgabe false?}
B -->|Ja| C[curl_errno und curl_error]
C --> D[Transportfehler behandeln]
B -->|Nein| E[curl_getinfo Statuscode]
E --> F{Status 2xx?}
F -->|Nein| G[HTTP-Fehler behandeln]
F -->|Ja| H[Antwort verarbeiten]
Header und Body auseinanderhalten
Mit CURLOPT_HEADER liefert PHP curl_exec beides in einem einzigen String. Der naheliegende Weg, an der ersten Leerzeile zu trennen, bricht bei Weiterleitungen: dort stehen mehrere Header-Blöcke hintereinander, und der Schnitt landet an der falschen Stelle.
<?php
$ch = curl_init('https://example.org/');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
CURLOPT_FOLLOWLOCATION => true,
]);
$roh = curl_exec($ch);
if ($roh === false) {
throw new RuntimeException(curl_error($ch));
}
/* cURL kennt die Gesamtlaenge aller Header-Bloecke */
$laenge = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$kopf = substr($roh, 0, $laenge);
$rumpf = substr($roh, $laenge);
CURLINFO_HEADER_SIZE zählt alle Header-Blöcke zusammen, auch die von Zwischenantworten. Der Schnitt sitzt damit immer richtig. Wer die Header einzeln braucht, nimmt stattdessen CURLOPT_HEADERFUNCTION und bekommt jede Zeile einzeln geliefert. Für reine Header-Abfragen ohne Body lohnt ein Blick auf PHP get_headers(), das für diesen Zweck deutlich weniger Code braucht.
Grosse Antworten streamen statt in den Speicher laden
Mit CURLOPT_RETURNTRANSFER gibt PHP curl_exec die vollständige Antwort als String zurück, und die liegt dann komplett im Arbeitsspeicher. Bei einer JSON-Antwort ist das egal, bei einem Export von mehreren hundert Megabyte sprengt es das Memory-Limit.
<?php
$ziel = fopen(__DIR__ . '/export.csv', 'wb');
if ($ziel === false) {
throw new RuntimeException('Zieldatei nicht schreibbar.');
}
$ch = curl_init('https://example.org/export/gross.csv');
curl_setopt_array($ch, [
CURLOPT_FILE => $ziel, /* statt RETURNTRANSFER */
CURLOPT_TIMEOUT => 600,
]);
$ok = curl_exec($ch);
fclose($ziel);
if ($ok === false) {
throw new RuntimeException(curl_error($ch));
}
Mit CURLOPT_FILE schreibt cURL direkt in den übergebenen Stream, und der Speicherbedarf bleibt konstant, egal wie gross die Datei ist. Beachte, dass CURLOPT_RETURNTRANSFER in diesem Fall nicht gesetzt sein darf. Brauchst du mehr Kontrolle, etwa um jede Zeile beim Empfang zu verarbeiten, nimm CURLOPT_WRITEFUNCTION mit einem eigenen Callback.
Mehrere Anfragen: Handle wiederverwenden
In einer Schleife jedes Mal curl_init() aufzurufen bedeutet, für jede Anfrage eine neue Verbindung samt TLS-Handshake aufzubauen. Bei fünfzig Anfragen summiert sich das spürbar.
<?php
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);
$ergebnisse = [];
foreach ([1, 2, 3, 4, 5] as $id) {
curl_setopt($ch, CURLOPT_URL, 'https://example.org/api/artikel/' . $id);
$antwort = curl_exec($ch);
$ergebnisse[$id] = $antwort === false
? 'Fehler: ' . curl_error($ch)
: $antwort;
}
/* Verbindung und TLS-Handshake entstehen nur einmal */
Solange dieselbe Gegenstelle angesprochen wird, hält cURL die Verbindung offen und nutzt sie erneut. Die Anfragen laufen dabei aber weiterhin nacheinander: zehn Anfragen zu je einer Sekunde brauchen zehn Sekunden.
Sollen sie sich zeitlich überlappen, tritt curl_multi_exec() an die Stelle von PHP curl_exec. Mehrere Handles kommen dabei in einen gemeinsamen Multi-Handle und werden gleichzeitig abgearbeitet.
<?php
$urls = [
'a' => 'https://example.org/api/artikel/1',
'b' => 'https://example.org/api/artikel/2',
'c' => 'https://example.org/api/artikel/3',
];
$multi = curl_multi_init();
$handles = [];
foreach ($urls as $name => $url) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);
curl_multi_add_handle($multi, $ch);
$handles[$name] = $ch;
}
/* Laufen lassen, bis keine Anfrage mehr aktiv ist */
do {
$status = curl_multi_exec($multi, $aktiv);
if ($aktiv) {
curl_multi_select($multi);
}
} while ($aktiv && $status === CURLM_OK);
$ergebnisse = [];
foreach ($handles as $name => $ch) {
$ergebnisse[$name] = curl_multi_getcontent($ch);
curl_multi_remove_handle($multi, $ch);
}
curl_multi_close($multi);
Zwei Punkte weichen hier vom gewohnten Ablauf ab. Die Antwort holst du nicht aus dem Rückgabewert, sondern mit curl_multi_getcontent() aus dem jeweiligen Handle. Und der Aufruf von curl_multi_select() in der Schleife ist wichtig: ohne ihn dreht die Schleife bei voller CPU-Last leer, statt auf Daten zu warten. Die Fehlerprüfung bleibt pro Handle dieselbe wie oben, mit curl_errno() und dem Statuscode. Der Aufbau einer einzelnen Session mit allen Konfigurationsmöglichkeiten steht im Tutorial zu PHP curl_init().
SSL-Zertifikatsfehler richtig lösen
Die Meldung "unable to get local issuer certificate" bedeutet, dass cURL das Zertifikat der Gegenstelle nicht prüfen kann, weil ihm die Liste vertrauenswürdiger Aussteller fehlt. In Foren steht dazu regelmässig ein Rat, der das Problem nicht löst, sondern die Sicherheit abschaltet.
<?php
/* FALSCH: schaltet die Pruefung ab und macht TLS wertlos
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); */
/* RICHTIG: aktuelles CA-Bundle angeben */
$ch = curl_init('https://example.org/');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CAINFO => '/etc/ssl/certs/ca-certificates.crt',
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
]);
/* Dauerhaft besser in der php.ini:
curl.cainfo = /etc/ssl/certs/ca-certificates.crt */
Mit abgeschalteter Prüfung ist die Verbindung zwar noch verschlüsselt, aber niemand prüft mehr, mit wem eigentlich. Genau davor schützt das Zertifikat. Der richtige Weg ist ein aktuelles CA-Bundle, entweder per Option oder dauerhaft in der php.ini.
Zwei Verwechslungen, die häufig auftauchen
Die erste betrifft den Namen. curl_exec() hat nichts mit exec() oder shell_exec() zu tun, auch wenn die Endung das nahelegt. Die beiden Letzteren führen Betriebssystembefehle aus und sind auf vielen Servern aus gutem Grund gesperrt. PHP curl_exec führt dagegen eine vorbereitete HTTP-Anfrage aus und braucht keine besonderen Rechte. Wer in einer Anleitung liest, man müsse "shell_exec freischalten", ist an der falschen Stelle gelandet.
Die zweite betrifft die Verfügbarkeit. Meldet PHP "Call to undefined function curl_exec()", fehlt die cURL-Extension. Das lässt sich vorab prüfen.
<?php
if (!extension_loaded('curl')) {
throw new RuntimeException('Die cURL-Extension ist nicht geladen.');
}
/* Version und unterstuetzte Protokolle ansehen */
$info = curl_version();
echo $info['version']; /* etwa 8.5.0 */
echo implode(', ', $info['protocols']);
Unter Debian und Ubuntu installiert apt install php-curl die Extension, danach muss der Webserver oder FPM-Dienst neu geladen werden. Bei Shared Hosting lässt sie sich meist im Kundenmenü zuschalten.
Braucht es curl_close() noch?
Seit PHP 8.0 sind cURL-Handles Objekte der Klasse CurlHandle und keine Ressourcen mehr. Damit räumt die Speicherverwaltung sie selbst auf, sobald die letzte Referenz verschwindet. curl_close() existiert weiter, tut aber nichts mehr. In neuem Code kann der Aufruf entfallen, in bestehendem Code schadet er nicht.
Fazit zu PHP curl_exec
PHP curl_exec meldet nur, ob der Transport geklappt hat. Was der Server geantwortet hat, steht woanders. Diese Trennung ist der Kern: false prüfen und den Statuscode aus curl_getinfo() holen, jedes Mal.
Dazu kommen drei Handgriffe, die sich in jeder Anfrage lohnen. Setze CURLOPT_RETURNTRANSFER, sonst landet die Antwort in deiner Seitenausgabe. Setze beide Timeouts, sonst kann ein fremder Server deine Seite blockieren. Und prüfe mit === false statt mit einer einfachen Wahrheitsprüfung, sonst wird eine leere, aber gültige Antwort zum vermeintlichen Fehler.