Ein Shop soll jede Bestellung als JSON an den Versanddienst melden, mit Token im Header und mit Zeitlimit. cURL ist auf dem Server nicht freigeschaltet, also bekommt file_get_contents() einen Stream-Kontext mit. Viele Varianten davon scheitern still: Der Token fehlt, ein POST kommt als leerer GET an, eine Fehlerseite wandert in den Cache. Einen einfachen POST zeigt das Tutorial zu file_get_contents(), eine Header-Abfrage mit HEAD und Zeitlimit das zu get_headers(). Hier geht es um den Kontext selbst: welcher Schlüssel welchen Wrapper steuert, was PHP stream_context_create() wirklich an den Server schickt und wo ein Kontext still das Falsche tut.
Gemessen wurde mit PHP 8.4 unter Windows und Linux, Versionsfragen mit 8.1 bis 8.5. Der Testserver auf 127.0.0.1:8765 schickt jede Anfrage Byte für Byte zurück.
Was PHP stream_context_create() an den Server schickt
Ein Stream-Kontext ist ein Bündel von Optionen, das eine Stream-Funktion beim Öffnen mitbekommt. PHP stream_context_create() selbst öffnet nichts. Erst fopen(), file_get_contents(), file() oder get_headers() lesen die Optionen, und zwar nur die, die zu ihrem Wrapper passen. Ohne Kontext kommt beim Server erstaunlich wenig an:
<?php
$url = 'http://127.0.0.1:8765/echo';
echo file_get_contents($url);
/* GET /echo HTTP/1.1
Host: 127.0.0.1:8765
Connection: close */
Kein User-Agent, kein Accept. Manche APIs weisen solche Anfragen ab. Bei einem POST ergänzt PHP die Länge des Rumpfs selbst. Beim Typ rät es dagegen und sagt das auch:
<?php
$context = stream_context_create([
'http' => [
'method' => 'POST',
'content' => 'name=Mia&menge=3',
],
]);
echo file_get_contents($url, false, $context);
/* Notice: file_get_contents(): Content-type not
specified assuming
application/x-www-form-urlencoded
POST /echo HTTP/1.1
Host: 127.0.0.1:8765
Connection: close
Content-Length: 16
Content-Type: application/x-www-form-urlencoded
name=Mia&menge=3 */
Wer JSON schickt und den Typ vergisst, bekommt einen Formulartyp untergeschoben. Das Diagramm zeichnet nach, wie ein Kontext aus PHP stream_context_create() zum passenden Wrapper findet:
flowchart TD
A[stream_context_create] --> B[Kontext]
B --> C{Schema der Adresse}
C -->|http oder https| D[Optionen unter http]
C -->|https| E[zusätzlich ssl]
C -->|ftp| F[Optionen unter ftp]
C -->|Datei| G[keine Wirkung]
D --> H[Anfrage an den Server]
E --> H
F --> H
B --> I[unbekannte Schlüssel]
I --> J[still ignoriert]
Die Signatur im Original: Array rein, Resource raus
PHP stream_context_create() nimmt zwei optionale Arrays entgegen:
stream_context_create(
?array $options = null,
?array $params = null
): resource
/* options - an associative array of arrays in
the format
$arr['wrapper']['option'] = $value
params - an associative array; the usual
key is notification, a callable
for progress events
return - a stream context resource of type
stream-context
A stream is a resource object which can be
read from or written to. The stream
functions share a common set of options:
pass the context to fopen(), file(),
file_get_contents() or get_headers() to use
a custom method, header or timeout and to
send an HTTP request with content. Unknown
options are ignored, a wrong format throws
a ValueError. */
PHP stream_context_create() liefert eine Resource vom Typ stream-context, auch in PHP 8.5 noch kein Objekt. Einen Fehlerwert gibt es nicht. Nur eine falsche Form wie ['http' => 'x'] wirft ValueError: Options should have the form ["wrappername"]["optionname"] = $value. Alles andere nimmt die Funktion kommentarlos an, auch Tippfehler. Dort beginnt der erste stille Fehler.
Welcher Schlüssel in PHP stream_context_create() welchen Wrapper steuert
Lea baut einen Abruf für eine Partner-API unter https:// und schreibt die Optionen deshalb unter den Schlüssel https. Beim Server kommt ein GET statt des POST an, ohne Warnung. Der Schlüssel benennt nicht das Schema der Adresse, sondern die Gruppe der Optionen:
| Schlüssel | gilt für | typische Optionen |
http | http:// und https:// | method, header, content, user_agent, timeout, ignore_errors, follow_location, proxy |
ssl | https://, ftps://, ssl://, tls:// | verify_peer, cafile, peer_name |
ftp | ftp:// und ftps:// | overwrite, resume_pos |
socket | alle Socket-Verbindungen | bindto, tcp_nodelay |
Gegen einen TLS-Testserver, dessen Zertifikat als vertrauenswürdig hinterlegt war, kam Folgendes an:
<?php
$url = 'https://localhost:8766/auftrag';
$falsch = stream_context_create([
'https' => ['method' => 'POST'],
]);
$richtig = stream_context_create([
'http' => ['method' => 'POST'],
]);
foreach ([$falsch, $richtig] as $context) {
$r = file_get_contents($url, false, $context);
echo strtok($r, "\r\n"), PHP_EOL;
}
/* GET /auftrag HTTP/1.1
POST /auftrag HTTP/1.1 */
Genauso verpufft ein Tippfehler wie 'timout' => 5, den stream_context_get_options() später unverändert anzeigt. Auch Optionen, die nicht zum Ziel passen, bleiben wirkungslos, etwa ssl-Einstellungen bei einer http-Adresse. Weil PHP stream_context_create() nichts davon meldet, lohnt eine kleine Prüfung beim Bauen des Kontexts:
<?php
function pruefe_optionen(array $optionen): array
{
$bekannt = ['http', 'ssl', 'ftp', 'socket'];
$fremd = array_diff(
array_keys($optionen),
$bekannt
);
if ($fremd !== []) {
throw new LogicException(
'Unbekannt: ' . implode(', ', $fremd)
);
}
return $optionen;
}
try {
pruefe_optionen(['https' => ['method' => 'POST']]);
} catch (LogicException $e) {
echo $e->getMessage();
}
/* Unbekannt: https */
Header und JSON mit file_get_contents(): warum der Token fehlt
Svenja bindet einen Versanddienstleister an. Die API verlangt JSON und einen Token im Header X-Token, antwortet aber mit 401. Die Header stammen aus einem älteren Beispiel mit \r\n zwischen den Zeilen, und beim Umbau auf ein Array ist jedes Zeilenende mitgewandert:
<?php
$json = json_encode(['paket' => 1]);
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => [
"Content-Type: application/json\r\n",
"X-Token: geheim\r\n",
],
'content' => $json,
],
]);
echo file_get_contents($url, false, $context);
/* POST /echo HTTP/1.1
Host: 127.0.0.1:8765
Connection: close
Content-Length: 11
Content-Type: application/json
X-Token: geheim
{"paket":1} */
Die Option header von PHP stream_context_create() nimmt einen String oder ein Array, dessen Elemente PHP mit \r\n verbindet. Endet ein Element selbst so, entsteht eine Leerzeile, und die beendet in HTTP den Header-Block. Der Token steht danach im Rumpf. Ein leeres Element wirkt genauso, ein abschließendes \r\n im String-Format entfernt PHP dagegen. Am sichersten führt man die Header als Paare:
<?php
function kopfzeilen(array $paare): array
{
$zeilen = [];
foreach ($paare as $name => $wert) {
$zeilen[] = trim($name) . ': '
. trim($wert);
}
return $zeilen;
}
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => kopfzeilen([
'Content-Type' => 'application/json',
'X-Token' => "geheim\r\n",
]),
'content' => $json,
],
]);
echo file_get_contents($url, false, $context);
/* POST /echo HTTP/1.1
Host: 127.0.0.1:8765
Connection: close
Content-Length: 11
Content-Type: application/json
X-Token: geheim
{"paket":1} */
Die Paare gehören aber nicht direkt in die Option. Ein assoziatives Array wie ['Content-Type' => 'application/json', 'X-Token' => 'geheim'] im header von PHP stream_context_create() verliert die Namen: Im Test kamen nur application/json und geheim als eigene Zeilen an, dazu der Notice zum fehlenden Typ und ein untergeschobenes Content-Type: application/x-www-form-urlencoded. Ein Array als content verschwindet sogar ohne jede Meldung, der POST geht ohne Content-Length und ohne Rumpf hinaus. Formulardaten aus einem Array baut http_build_query(), JSON baut json_encode():
<?php
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => 'Content-Type: '
. 'application/x-www-form-urlencoded',
'content' => http_build_query(
['name' => 'Mia', 'menge' => 3]
),
],
]);
echo file_get_contents($url, false, $context);
/* POST /echo HTTP/1.1
Host: 127.0.0.1:8765
Connection: close
Content-Length: 16
Content-Type: application/x-www-form-urlencoded
name=Mia&menge=3 */
Beim User-Agent schlägt ein Eintrag im header die Option user_agent, und die Option schlägt die php.ini. Zugangsdaten in der Adresse wie http://mia:geheim@... setzt PHP selbst in einen Authorization-Header um.
Einen Header, den jeder Browser sendet, sollte man nicht übernehmen: Accept-Encoding: gzip. Der Wrapper entpackt nicht, und json_decode() scheitert an den komprimierten Bytes:
<?php
$context = stream_context_create([
'http' => ['header' => 'Accept-Encoding: gzip'],
]);
$roh = file_get_contents(
'http://127.0.0.1:8765/wetter.json',
false,
$context
);
var_dump(json_decode($roh));
/* NULL */
echo json_last_error_msg(), PHP_EOL;
/* Control character error, possibly
incorrectly encoded */
echo gzdecode($roh);
/* {"ort":"Bremen","grad":12.5} */
Entweder fehlt der Header, oder die Antwort läuft durch gzdecode(). Mit compress.zlib:// vor der Adresse entpackt PHP schon beim Lesen.
Weiterleitungen: warum aus einem POST ein leerer GET wird
Andrea meldet Zahlungen per POST an das System eines Partners. Nach dessen Umzug leitet die alte Adresse mit 301 auf die neue um. Ihr Skript meldet weiter Erfolg, beim Partner kommen aber nur leere Anfragen an:
<?php
$alteUrl = 'http://127.0.0.1:8765/alt';
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => 'Content-Type: application/json',
'content' => '{"status":"bezahlt"}',
],
]);
echo file_get_contents($alteUrl, false, $context);
/* GET /neu HTTP/1.1
Host: 127.0.0.1:8765
Connection: close */
Ein Kontext aus PHP stream_context_create() folgt Weiterleitungen von sich aus, wechselt aber auf GET und lässt Rumpf und Content-Type weg. Die Antwort des Ziels kommt mit 200, also sieht alles nach Erfolg aus. Entscheidend ist der Statuscode, gemessen in PHP 8.1, 8.3, 8.4 und 8.5:
| Antwort des Servers | zweite Anfrage | Rumpf |
| 301, 302, 303 | GET | fehlt |
| 307, 308 | POST | wird mit Content-Type erneut gesendet |
Bei schreibenden Anfragen gehört die Weiterleitung deshalb abgeschaltet und als Fehler behandelt:
<?php
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => 'Content-Type: application/json',
'content' => '{"status":"bezahlt"}',
'follow_location' => 0,
'ignore_errors' => true,
],
]);
$body = file_get_contents($alteUrl, false, $context);
$kopf = http_get_last_response_headers();
echo $kopf[0], PHP_EOL;
/* HTTP/1.1 301 Moved Permanently */
echo implode(preg_grep('/^Location:/i', $kopf));
/* Location: /neu */
Jetzt geht keine leere Anfrage mehr hinaus, und das Skript sieht die 301 samt Ziel. http_get_last_response_headers() gibt es ab PHP 8.4, eine Variante für ältere Versionen folgt weiter unten. Vorbeugen lässt sich mit der endgültigen Adresse.
timeout ist keine Gesamtfrist
Murat lädt nachts Preislisten von Lieferanten und hat 'timeout' => 5 gesetzt, trotzdem läuft der Cronjob bei einem Lieferanten minutenlang. Ein Testserver, der 100.000 Bytes in zehn Portionen mit je 50 ms Pause schickt, zeigt den Grund:
<?php
$context = stream_context_create([
'http' => ['timeout' => 0.2],
]);
$start = microtime(true);
$daten = file_get_contents(
'http://127.0.0.1:8765/preisliste',
false,
$context
);
printf('%d Bytes in %.2f s', strlen($daten),
microtime(true) - $start);
/* 100000 Bytes in 0.50 s */
Obwohl die Übertragung länger als 0,2 Sekunden dauert, kommt alles an, unter Windows nach gut 0,6 s. Die Option timeout in PHP stream_context_create() begrenzt die Wartezeit auf das nächste Datenpaket, nicht die Gesamtdauer. Ohne Kontext gilt default_socket_timeout aus der php.ini, meist 60 Sekunden. Auf eine Sekunde gesetzt, brach eine Anfrage an einen schweigenden Server nach 1,00 s mit Failed to open stream: HTTP request failed! ab. Eine echte Frist misst das Skript selbst:
<?php
function lade_mit_frist(
string $url,
float $frist
): string {
$ende = microtime(true) + $frist;
$context = stream_context_create([
'http' => ['timeout' => $frist],
]);
$h = @fopen($url, 'r', false, $context);
if ($h === false) {
throw new RuntimeException('keine Antwort');
}
$daten = '';
while (!feof($h)) {
if (microtime(true) > $ende) {
fclose($h);
throw new RuntimeException(
'Frist abgelaufen'
);
}
$daten .= fread($h, 8192);
}
fclose($h);
return $daten;
}
try {
lade_mit_frist(
'http://127.0.0.1:8765/preisliste',
0.25
);
} catch (RuntimeException $e) {
echo $e->getMessage();
}
/* Frist abgelaufen */
Mit 0,25 Sekunden brach die Funktion nach 0,25 s ab (Windows 0,25 bis 0,31 s), mit zwei Sekunden kam die ganze Liste an. Gegen einen Server, der drei Sekunden schweigt, meldete sie bei einer Frist von einer Sekunde nach 1,00 s keine Antwort. Ein einzelnes fread() kann bis zum timeout warten, die Frist wird zwischen zwei Lesevorgängen geprüft.
Der Wert von timeout gilt auch für den Verbindungsaufbau, und das trifft Entwickler unter Windows. Dort versucht PHP bei localhost zuerst die IPv6-Adresse ::1. Lauscht der Dienst nur unter 127.0.0.1, vergehen rund zwei Sekunden, bis Windows aufgibt:
<?php
$context = stream_context_create([
'http' => ['timeout' => 0.5],
]);
$start = microtime(true);
$r = @file_get_contents(
'http://localhost:8765/echo', false, $context
);
printf('%s nach %.1f s', $r === false ? 'false'
: 'Antwort', microtime(true) - $start);
/* Windows: false nach 0.5 s
Linux: Antwort nach 0.0 s */
Ohne Zeitlimit kam die Antwort unter Windows erst nach gut zwei Sekunden. Mit 0,5 Sekunden endete die Anfrage im Test mit false. Die Meldung erscheint in der Sprache des Systems und begann im Test mit Failed to open stream: Ein Verbindungsversuch ist fehlgeschlagen, da die Gegenstelle nach einer bestimmten Zeitspanne nicht richtig reagiert hat. Abhilfe schafft 127.0.0.1 statt localhost oder ein Dienst, der auch auf ::1 lauscht.
Fehlerstatus prüfen, statt Fehlerseiten zu speichern
Tim zeigt auf der Vereinsseite das Wetter an und cacht die Antwort der API eine Stunde. Bei einem Fehler liefert file_get_contents() nur false und die Warnung Failed to open stream: HTTP request failed! HTTP/1.1 500 Internal Server Error. Um die Fehlermeldung der API zu sehen, schaltet er ignore_errors ein:
<?php
$apiUrl = 'http://127.0.0.1:8765/fehler';
$context = stream_context_create([
'http' => ['ignore_errors' => true],
]);
$wetter = file_get_contents($apiUrl, false, $context);
var_dump($wetter);
/* string(20) "{"fehler":"Wartung"}" */
Seit Tim ignore_errors an PHP stream_context_create() übergibt, kommt bei einem Fehler kein false mehr, sondern die Fehlerseite, ohne Warnung, und sein Cache speichert sie als Wetterdaten. Prüfen muss jetzt das eigene Skript, und zwar die letzte Statuszeile, denn nach einer Weiterleitung stehen mehrere Antworten hintereinander:
<?php
function letzter_status(array $kopf): int
{
$status = 0;
$muster = '#^HTTP/\S+ (\d{3})#';
foreach ($kopf as $zeile) {
if (preg_match($muster, $zeile, $t)) {
$status = (int) $t[1];
}
}
return $status;
}
function letzte_kopfzeilen(?array $lokal): array
{
if (function_exists(
'http_get_last_response_headers'
)) {
return http_get_last_response_headers()
?? [];
}
return $lokal ?? [];
}
$wetter = file_get_contents($apiUrl, false, $context);
$kopf = letzte_kopfzeilen(
$http_response_header ?? null
);
echo letzter_status($kopf);
/* 500 */
Liefert die Prüfung etwas anderes als 200, landet nichts im Cache. letzte_kopfzeilen() hat einen Grund, der erst mit PHP 8.5 sichtbar wird. Die Header stehen in $http_response_header, ab 8.4 auch in http_get_last_response_headers(). Nina setzt PHP stream_context_create() in einer Bibliothek für 8.1 bis 8.5 ein und verbindet beides per Versionsweiche. Unter 8.5 meldet ihre CI trotzdem Deprecated: The predefined locally scoped $http_response_header variable is deprecated, call http_get_last_response_headers() instead.
Die Meldung entsteht schon beim Kompilieren, im Test auch für eine Funktion, die nie aufgerufen wurde. Eine Versionsweiche hilft also nicht. Sie kommt bei jedem direkten Lesen der Variablen, aber nicht bei isset() und nicht beim Null-Koaleszenz-Operator. Deshalb übergibt der Aufruf oben $http_response_header ?? null: Unter PHP 8.1 kommen die Header aus der Variablen, unter 8.5 bleibt es still. Welche Wege es zu den Headern sonst gibt, zeigt das Tutorial zu stream_get_meta_data().
Proxy, HTTPS und FTP: Streams jenseits von HTTP
Claudia arbeitet im Intranet eines Energieversorgers, Anfragen nach außen laufen über den Firmenproxy mit Anmeldung. PHP stream_context_create() bekommt dessen Adresse mit tcp://. Entscheidend ist request_fulluri: Ohne die Option schickt PHP dem Proxy nur den Pfad (GET /status HTTP/1.1), und die meisten Proxys wissen dann nicht, wohin. Im Test spielte der Testserver den Proxy:
<?php
$context = stream_context_create([
'http' => [
'proxy' => 'tcp://127.0.0.1:8765',
'request_fulluri' => true,
'header' => 'Proxy-Authorization: Basic '
. base64_encode('mia:geheim'),
],
]);
echo file_get_contents(
'http://intranet.example/status',
false,
$context
);
/* GET http://intranet.example/status HTTP/1.1
Host: intranet.example
Connection: close
Proxy-Authorization: Basic bWlhOmdlaGVpbQ== */
Für https kommen die TLS-Optionen unter ssl dazu. Ein Server mit Zertifikat aus der firmeneigenen Zertifizierungsstelle scheitert ohne weitere Angaben mit drei Meldungen, die erste lautet SSL operation failed with code 1. OpenSSL Error messages: error:0A000086:SSL routines::certificate verify failed, danach folgen Failed to enable crypto und Failed to open stream: operation failed. Mit cafile klappt die Verbindung. Wer den Server über seine IP-Adresse anspricht, scheitert erneut mit Peer certificate CN=`localhost' did not match expected CN=`127.0.0.1'. Dafür gibt es peer_name:
<?php
$context = stream_context_create([
'ssl' => [
'cafile' => __DIR__ . '/firma-ca.pem',
'peer_name' => 'localhost',
],
]);
$antwort = file_get_contents(
'https://127.0.0.1:8766/status',
false,
$context
);
echo strtok($antwort, "\r\n");
/* GET /status HTTP/1.1 */
verify_peer auf false hilft hier nicht. Es schaltet nur die Prüfung der Zertifizierungsstelle ab, den Namen vergleicht PHP weiter: Über die IP-Adresse kam im Test wieder die CN-Meldung, gefolgt von Failed to enable crypto und Failed to open stream: operation failed. Erst mit zusätzlich verify_peer_name auf false verschwand sie, und dann nimmt PHP jedes Zertifikat an. peer_name dagegen behält beide Prüfungen und sagt PHP nur, welcher Name im Zertifikat stehen muss. Einzelheiten zur Zertifikatsprüfung stehen im Tutorial zu stream_socket_client().
Auch für FTP nimmt PHP stream_context_create() Optionen entgegen. Dirk lädt jede Nacht eine Preisliste auf einen Marktplatz. Ohne Option weigert sich PHP, eine vorhandene Datei zu ersetzen: Remote file already exists and overwrite context option not specified. Mit overwrite klappte es, bis nach einem Verbindungsabbruch die Liste ganz fehlte. Das Protokoll eines Test-FTP-Servers zeigt, warum:
<?php
$csv = "artikel;preis\nA-100;9.90\n";
$context = stream_context_create([
'ftp' => ['overwrite' => true],
]);
file_put_contents(
'ftp://mia:geheim@127.0.0.1:2121/preise.csv',
$csv,
0,
$context
);
/* Befehle am Server:
USER mia, PASS geheim, TYPE I,
SIZE /preise.csv, DELE /preise.csv,
EPSV, STOR /preise.csv */
Mit overwrite löscht PHP die alte Datei, bevor die neue geschrieben ist. Reißt die Verbindung nach DELE ab, ist keine Datei mehr da. Sicherer ist ein temporärer Name, denn rename() funktioniert auch mit ftp-Adressen:
<?php
$basis = 'ftp://mia:geheim@127.0.0.1:2121';
file_put_contents("$basis/preise.tmp", $csv);
rename("$basis/preise.tmp", "$basis/preise.csv");
/* Befehle am Server nach der Anmeldung:
TYPE I, SIZE /preise.tmp, EPSV,
STOR /preise.tmp, dann neue Verbindung:
RNFR /preise.tmp, RNTO /preise.csv */
Ob RNTO ein vorhandenes Ziel ersetzt, entscheidet der Server. Für abgebrochene Downloads gibt es resume_pos, PHP schickt dann vor dem Abruf ein REST.
Der Standardkontext und seine Nebenwirkungen
Statt den Proxy überall einzutragen, setzt Claudia ihn einmal mit stream_context_set_default(). Er gilt für alle Aufrufe ohne eigenen Kontext, im Test für file_get_contents(), get_headers() und simplexml_load_file():
<?php
stream_context_set_default([
'http' => [
'user_agent' => 'Intranet/1.0',
'timeout' => 10,
],
]);
echo file_get_contents('http://127.0.0.1:8765/a');
/* GET /a HTTP/1.1
Host: 127.0.0.1:8765
Connection: close
User-Agent: Intranet/1.0 */
$eigener = stream_context_create([
'http' => ['header' => 'Accept: text/plain'],
]);
echo file_get_contents(
'http://127.0.0.1:8765/b', false, $eigener
);
/* GET /b HTTP/1.1
Host: 127.0.0.1:8765
Connection: close
Accept: text/plain */
Der zweite Aufruf zeigt die Grenze: Was mit PHP stream_context_create() entsteht, erbt nichts vom Standard. Gemeinsame Einstellungen gehören deshalb in eine Funktion, die alle eigenen Kontexte baut. Für libxml gibt es libxml_set_streams_context(). Vorsicht bei stream_context_get_default(): Mit einem Argument ändert sie den Standard dauerhaft, statt eine Kopie zu liefern.
<?php
stream_context_get_default([
'http' => ['timeout' => 5],
]);
$h = fopen('http://127.0.0.1:8765/c', 'r');
print_r(stream_context_get_options($h)['http']);
/* Array
(
[user_agent] => Intranet/1.0
[timeout] => 5
) */
Der vermeintliche Lesezugriff hat das Zeitlimit aller späteren Aufrufe ohne Kontext auf fünf Sekunden gesetzt. get_headers() sendet ohne Kontext übrigens einen GET, für reine Header setzt man 'method' => 'HEAD'.
Einen mit PHP stream_context_create() erzeugten Kontext ändert stream_context_set_option() mit vier Argumenten, wirksam ab der nächsten Anfrage. Die Form mit einem Array als zweitem Argument meldet seit PHP 8.4 Deprecated: Calling stream_context_set_option() with 2 arguments is deprecated, use stream_context_set_options() instead. stream_context_set_options() gibt es ab PHP 8.3.
Fortschritt anzeigen mit dem notification-Callback
Nina zeigt beim Download großer Feeds einen Fortschritt in der Konsole. Dafür nimmt PHP stream_context_create() im zweiten Parameter einen Callback entgegen, den PHP bei jedem Ereignis der Übertragung aufruft:
<?php
function fortschritt(
int $code, int $stufe, ?string $text,
int $nr, int $bytes, int $max
): void {
static $gemeldet = -1;
if ($code === STREAM_NOTIFY_FILE_SIZE_IS) {
echo "Größe: $max Bytes\n";
} elseif ($code === STREAM_NOTIFY_PROGRESS
&& $max > 0) {
$prozent = intdiv($bytes * 100, $max);
if (intdiv($prozent, 25) > $gemeldet) {
$gemeldet = intdiv($prozent, 25);
echo "$prozent %\n";
}
}
}
$context = stream_context_create(
[],
['notification' => 'fortschritt']
);
$daten = file_get_contents(
'http://127.0.0.1:8765/preisliste',
false,
$context
);
/* Größe: 100000 Bytes
0 %
28 %
50 %
78 %
100 % */
Die Ereignisse kamen im Test immer in derselben Reihenfolge: STREAM_NOTIFY_CONNECT, MIME_TYPE_IS, FILE_SIZE_IS mit der Größe aus Content-Length, PROGRESS und am Ende COMPLETED. Bei einer Weiterleitung kommt REDIRECTED dazu, bei 404 oder 401 FAILURE mit der Statuszeile. Wer den Callback nicht an PHP stream_context_create() übergibt, sondern erst nach fopen() mit stream_context_set_params() setzt, bekommt keine Fortschrittsmeldung mehr, im Test höchstens noch ein COMPLETED mit null Bytes. Ein Tippfehler im Funktionsnamen fällt erst bei der Anfrage auf: Invalid callback fortschrit, function "fortschrit" not found or invalid function name.
Praxis: ein API-Client mit PHP stream_context_create()
Zurück zum Shop vom Anfang. Die Funktion meldet eine Bestellung an den Versanddienst und setzt um, was die Abschnitte oben zu PHP stream_context_create() gemessen haben: Header als Paare, eigener User-Agent, Zeitlimit, keine Weiterleitung bei POST, eigene Statusprüfung und Header ohne Deprecation. kopfzeilen(), letzter_status() und letzte_kopfzeilen() stammen aus den Beispielen oben.
<?php
function api_post(
string $url,
array $daten,
string $token,
float $frist = 5.0
): array {
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => kopfzeilen([
'Content-Type' => 'application/json',
'Accept' => 'application/json',
'Authorization' => "Bearer $token",
]),
'user_agent' => 'Bestellsync/1.0',
'content' => json_encode($daten),
'timeout' => $frist,
'ignore_errors' => true,
'follow_location' => 0,
],
]);
$body = @file_get_contents($url, false, $context);
if ($body === false) {
$f = error_get_last()['message'] ?? '?';
throw new RuntimeException($f);
}
$kopf = letzte_kopfzeilen(
$http_response_header ?? null
);
$status = letzter_status($kopf);
if ($status < 200 || $status >= 300) {
throw new RuntimeException(
"HTTP $status: " . substr($body, 0, 80)
);
}
return json_decode(
$body, true, 512, JSON_THROW_ON_ERROR
);
}
try {
$antwort = api_post(
'http://127.0.0.1:8765/bestellung',
['id' => 7, 'menge' => 2],
'geheim'
);
echo 'Auftrag ', $antwort['auftrag'];
} catch (Exception $e) {
error_log('Versand: ' . $e->getMessage());
}
/* Auftrag 4711 */
Das @ unterdrückt nur die Ausgabe der Warnung, error_get_last() holt ihren Text in die Exception. JsonException erbt nicht von RuntimeException, deshalb fängt der Aufruf Exception. Gegen fünf Antworten des Testservers verhielt sich die Funktion auf beiden Systemen und unter PHP 8.1 und 8.5 gleich:
| Antwort des Servers | Ergebnis von api_post() |
| 200 mit JSON | Array mit auftrag 4711 |
| 500 mit Fehlerseite | RuntimeException: HTTP 500: {"fehler":"Wartung"} |
| 302 auf eine neue Adresse | RuntimeException: HTTP 302, keine zweite Anfrage |
| keine Antwort innerhalb der Frist | RuntimeException, Text endet auf HTTP request failed! |
| 200 mit HTML statt JSON | JsonException: Syntax error |
Keiner der fünf Fälle endet als stilles Weiterarbeiten mit falschen Daten.
Wann cURL die bessere Wahl ist
In drei Fällen kommt man mit PHP stream_context_create() nicht weiter, dort ist cURL die bessere Wahl:
allow_url_fopen ist abgeschaltet Hat der Hoster die Einstellung abgeschaltet, scheitert jeder Kontext mit http:// wrapper is disabled in the server configuration by allow_url_fopen=0 und no suitable wrapper could be found. Eine Prüfung vorab erkennt den Fall, bevor die erste Anfrage scheitert.
<?php
$erlaubt = filter_var(
ini_get('allow_url_fopen'),
FILTER_VALIDATE_BOOL
);
var_dump($erlaubt);
/* bool(true), mit allow_url_fopen=0 bool(false) */
Der Dienst verlangt HTTP/2 Mit 'protocol_version' => 2.0 schreibt PHP nur GET /echo HTTP/2.0 in eine gewöhnliche Textanfrage. Echtes HTTP/2 spricht der Wrapper nicht, die Push-Dienste von Apple setzen es aber voraus. Chunked-Antworten unter dem Standard HTTP/1.1 setzt PHP korrekt zusammen.
Parallele Anfragen und harte Fristen file_get_contents() wartet die komplette Antwort ab, bevor die nächste Anfrage beginnt. Zwanzig Lieferanten parallel abzufragen, gelingt mit cURL am einfachsten über curl_multi_exec(), und auch Gesamtfrist und gzip erledigt cURL über Optionen.
Fazit
PHP stream_context_create() macht aus file_get_contents() einen brauchbaren HTTP-Client, wenn man weiß, was der Kontext sendet. HTTP-Optionen stehen immer unter http, unbekannte Schlüssel bleiben ohne Wirkung und ohne Meldung, und Header gehören ohne eigenes Zeilenende ins Array. Bei schreibenden Anfragen schaltet man die Weiterleitung ab, weil ein POST 301, 302 und 303 nicht übersteht.
Die Option timeout von PHP stream_context_create() begrenzt nur die Wartezeit auf das nächste Paket. Zu ignore_errors gehört immer eine Statusprüfung, und $http_response_header liest man nur noch mit ??. Beim FTP-Upload ist ein temporärer Name sicherer als overwrite, und für HTTP/2 oder parallele Anfragen bleibt cURL das bessere Werkzeug.