Ein Lager sammelt die Barcodes seiner Handscanner, und jedes Gerät schickt seine Zeilen an einen festen Port auf dem Server. Dort muss ein Programm warten, Verbindungen annehmen und mehrere Scanner gleichzeitig bedienen. Wie ein PHP-Skript als Client eine Verbindung aufbaut, zeigt das Tutorial zu stream_socket_client(). Die Gegenseite ist PHP stream_socket_server(): Die Funktion öffnet den Port und wartet auf Clients.
Alle Beispiele binden 127.0.0.1 und laufen auf der Kommandozeile. Alle Fehlermeldungen stammen aus Testläufen unter Windows und Linux mit PHP 8.4.
Was PHP stream_socket_server() macht: Sockets als Streams
Die Funktion bindet eine Adresse und liefert einen Server-Socket zurück. Auf diesem Socket wird weder gelesen noch geschrieben, er nimmt nur Verbindungen entgegen. Erst stream_socket_accept() gibt für jeden Client einen eigenen Stream heraus, und auf dem arbeiten fgets(), fread() und fwrite() wie bei einer Datei.
Sie gehört zum Kern von PHP. Auf beiden Testsystemen war die Sockets-Erweiterung nicht geladen, socket_create() existierte dort gar nicht, und der Server lief trotzdem. Die Sockets-Erweiterung bildet die C-Schnittstelle der Berkeley Sockets nach, mit getrennten Aufrufen für Binden, Lauschen und Annehmen. Die Stream-Variante fasst Binden und Lauschen in einem Aufruf zusammen und beherrscht TLS über dieselbe Adresse.
Ein solcher Server ist ein langlebiger Prozess und gehört auf die Kommandozeile (php server.php). Im Browser hängt der Aufruf, bis der Webserver abbricht, denn max_execution_time greift nicht, solange PHP in stream_socket_accept() wartet. Ein Server mit drei Sekunden Limit wartete im Test nach sieben Sekunden noch immer. Der eingebaute Webserver php -S ist etwas anderes: Er spricht HTTP, ein eigener Socket-Server legt sein Protokoll selbst fest.
Die Signatur im Original
Das Gerüst jedes Servers besteht aus stream_socket_server() und stream_socket_accept():
stream_socket_server(
string $address,
int &$error_code = null,
string &$error_message = null,
int $flags = STREAM_SERVER_BIND
| STREAM_SERVER_LISTEN,
?resource $context = null
): resource|false
stream_socket_accept(
resource $socket,
?float $timeout = null,
string &$peer_name = null
): resource|false
/* address - transport://host:port, port 0 picks
any free port; stream_get_transports()
lists the available transports
flags - use STREAM_SERVER_BIND alone for udp
accept - waits for a connection on the server
socket and returns a new stream;
timeout null = default_socket_timeout */
Der Standardwert von $flags ist 12, die Summe aus STREAM_SERVER_BIND (4) und STREAM_SERVER_LISTEN (8). Nur bei UDP fällt das Lauschen weg. Beim Timeout von stream_socket_accept() gilt ohne Angabe default_socket_timeout, also meist 60 Sekunden. Mit -1 wartet die Funktion unbegrenzt, im Test auch dann, als default_socket_timeout auf eine Sekunde stand.
Ein erster TCP-Server mit stream_socket_accept()
Der kleinste sinnvolle Server mit PHP stream_socket_server() nimmt eine Verbindung an, liest eine Zeile, schickt sie mit einem Präfix zurück und schließt wieder:
<?php
/* Start: php echo-server.php */
$server = stream_socket_server(
'tcp://127.0.0.1:8000',
$errno,
$errstr
);
if ($server === false) {
fwrite(STDERR, "Start fehlgeschlagen: $errstr\n");
exit(1);
}
while (true) {
$conn = stream_socket_accept($server, -1, $peer);
if ($conn === false) {
continue;
}
$zeile = fgets($conn, 4096);
if ($zeile !== false) {
fwrite($conn, 'Echo: ' . $zeile);
}
fclose($conn);
}
Zum Testen genügt ein zweites Skript. Unter Linux tut es auch nc 127.0.0.1 8000.
<?php
$client = stream_socket_client('tcp://127.0.0.1:8000');
fwrite($client, "hallo\n");
echo fgets($client);
/* Echo: hallo */
In $peer steht danach die Adresse der Gegenstelle, etwa 127.0.0.1:37200. Trennt der Client die Verbindung, liefert fgets() auf der Serverseite false, feof() meldet true und fread() einen leeren String. Beim Schreiben fällt eine Trennung erst spät auf: Der erste fwrite() nach dem Abbruch lieferte im Test noch eine 1, erst der zweite scheiterte mit Send of 1 bytes failed with errno=32 Broken pipe, unter Windows mit errno 10053. Den Rückgabewert von fwrite() prüft man deshalb bei jedem Aufruf.
Adresse und Port: 127.0.0.1, 0.0.0.0 und Port 0
Die Adresse für PHP stream_socket_server() folgt demselben Schema wie beim Client. Serverseitig entscheidet sie zusätzlich, wer den Dienst überhaupt erreicht. Wer die IP-Adresse 127.0.0.1 bindet, erreicht seinen Server nur vom selben Rechner aus, für interne Dienste genau richtig. Wer stattdessen localhost einträgt, bekommt auf beiden Testsystemen einen Server, der nur auf [::1] lauscht, also auf IPv6. Ein Client, der 127.0.0.1 anwählt, scheitert dann: unter Linux sofort mit Connection refused, unter Windows nach rund zwei Sekunden mit derselben Ablehnung (errno 10061).
0.0.0.0 öffnet den Server auf allen IPv4-Schnittstellen und damit für jeden im Netz. Das ist der Weg für die Handscanner im Lager, aber nur zusammen mit einer Firewall-Regel und einer eigenen Anmeldung im Protokoll. Mit [::] lauscht der Server unter Linux auf IPv6 und IPv4 zugleich, unter Windows nur auf IPv6. Erst die Kontextoption 'ipv6_v6only' => false öffnet dort beide Wege.
Port 0 überlässt die Wahl dem Betriebssystem. Welcher Port es wurde, verrät stream_socket_get_name():
<?php
$server = stream_socket_server('tcp://127.0.0.1:0');
echo stream_socket_get_name($server, false);
/* 127.0.0.1:53602, Port vom System */
Vorsicht bei Portnummern über 65535. PHP stream_socket_server() lehnt sie nicht ab, sondern rechnet still um: Aus tcp://127.0.0.1:70000 wurde auf beiden Systemen Port 4464. Wer Ports aus einer Konfiguration liest, prüft den Bereich deshalb selbst.
Wenn PHP stream_socket_server() false liefert
Scheitert der Start, liefert PHP stream_socket_server() false und eine Warnung, die seltsamerweise von „connect“ spricht, obwohl gebunden wird. Die häufigsten Ursachen mit den wörtlichen Meldungen aus dem Linux-Test:
| Ursache | Meldung |
| Port belegt | Unable to connect to tcp://127.0.0.1:35859 (Address already in use) |
| Port fehlt | Failed to parse address "127.0.0.1" |
| Adresse gehört nicht zum Rechner | Cannot assign requested address |
| Tippfehler im Transport | Unable to find the socket transport "tpc" - did you forget to enable it when you configured PHP? |
| UDP mit Standard-Flags | Unable to connect to udp://127.0.0.1:0 (Unknown error) |
In allen fünf Fällen stand in $errno eine 0. Die Ursache steckt in $errstr und in der Warnung, bei UDP und Unix-Sockets sogar nur in der Warnung, weil $errstr dort leer bleibt.
Unter Windows verhält sich der belegte Port ganz anders. Dort startete PHP stream_socket_server() in einem zweiten Prozess auf demselben Port ohne jede Meldung, und alle sechs Testverbindungen landeten beim ersten. Weder so_reuseaddr noch so_reuseport im Socket-Kontext änderten daran etwas. Abhilfe schafft eine Probeverbindung vor dem Start:
<?php
$adresse = 'tcp://127.0.0.1:8000';
/* Windows meldet einen belegten Port nicht */
$probe = @stream_socket_client($adresse, $e, $t, 0.5);
if ($probe !== false) {
fclose($probe);
exit("Auf dem Port läuft schon ein Server\n");
}
$server = @stream_socket_server($adresse, $e, $t);
if ($server === false) {
/* $e ist hier 0, die Ursache steht in $t */
exit("Start fehlgeschlagen: $t\n");
}
Ein Neustart direkt nach dem Beenden klappte auf beiden Systemen, selbst wenn eben noch ein Client verbunden war. Wer unter Linux „Address already in use“ liest, hat also wirklich noch einen laufenden Prozess. Ports unter 1024 darf auf einem gewöhnlichen Linux-Server nur root binden. Docker hebt diese Grenze oft auf, ein Test im Container beweist hier also nichts.
Mehrere Clients mit stream_select()
Solange der Echo-Server mit einem Client beschäftigt ist, warten alle anderen. Für die Handscanner braucht es eine Schleife, die den Server-Socket und alle Verbindungen zugleich überwacht und nur dort handelt, wo etwas anliegt. Eine Runde läuft so ab:
flowchart TD
A[Server-Socket] --> B[stream_select wartet]
B --> C{Wer ist lesbar?}
C -->|Server-Socket| D[stream_socket_accept]
D --> E[Client in die Liste]
E --> B
C -->|Client| F[Daten lesen]
F --> G{Verbindung getrennt?}
G -->|ja| H[fclose und entfernen]
G -->|nein| I[antworten mit fwrite]
H --> B
I --> B
Im Code steht der Server-Socket im selben Lesearray wie die Verbindungen. Wird er lesbar, wartet ein neuer Client:
<?php
$server = stream_socket_server('tcp://127.0.0.1:8000');
$clients = [];
$nr = 0;
while (true) {
$lesen = $clients;
$lesen['server'] = $server;
$w = $e = null;
$n = stream_select($lesen, $w, $e, null);
if ($n === false) {
break;
}
foreach ($lesen as $id => $strom) {
if ($id === 'server') {
$neu = stream_socket_accept($server, 0);
if ($neu !== false) {
if (count($clients) >= 500) {
fclose($neu);
continue;
}
$clients['c' . ++$nr] = $neu;
}
continue;
}
$zeile = fgets($strom, 4096);
if ($zeile === false) {
fclose($strom);
unset($clients[$id]);
continue;
}
fwrite($strom, "OK $id: $zeile");
}
}
stream_select() verändert die übergebenen Arrays und lässt nur die lesbaren Streams darin stehen. Deshalb wird $lesen in jeder Runde neu gebaut. Die Schlüssel bleiben erhalten (im Test c1, c2, server) und taugen deshalb als Kennung der Verbindung. Ein getrennter Client meldet sich ebenfalls als lesbar, fgets() liefert dann false. Fehlt der Server-Socket im Array und ist der letzte Client gegangen, wirft PHP ValueError: No stream arrays were passed.
Blockierend oder nicht: Timeouts auf der Verbindung
Die Schleife um PHP stream_socket_server() hat noch zwei Schwächen. Die erste betrifft halbe Zeilen: Ein Client schickte im Test abc ohne Zeilenumbruch. stream_select() meldete die Verbindung als lesbar, und fgets() wartete trotzdem auf das Zeilenende, bis default_socket_timeout abgelaufen war. In dieser Zeit stand der ganze Server still. Mit dem Standardwert sind das bis zu 60 Sekunden je halber Zeile.
Der Ausweg ist der nicht blockierende Modus. Darin liefert fgets() sofort, was da ist, im Test die halbe Zeile 'abc'. Ein Puffer je Verbindung sammelt die Bruchstücke, und verarbeitet werden nur vollständige Zeilen. Der Lese-Teil der Schleife sieht dann so aus:
/* nach dem Annehmen eines neuen Clients: */
$id = 'c' . ++$nr;
stream_set_blocking($neu, false);
$clients[$id] = $neu;
$puffer[$id] = '';
/* beim Lesen in der Schleife: */
$daten = fread($strom, 8192);
if ($daten === '' && feof($strom)) {
fclose($strom);
unset($clients[$id], $puffer[$id]);
continue;
}
$puffer[$id] .= $daten;
while (($pos = strpos($puffer[$id], "\n")) !== false) {
$zeile = substr($puffer[$id], 0, $pos + 1);
$puffer[$id] = substr($puffer[$id], $pos + 1);
fwrite($strom, "OK: $zeile");
}
/* mehr als 4 KiB ohne Zeilenende: trennen */
if (strlen($puffer[$id]) > 4096) {
fclose($strom);
unset($clients[$id], $puffer[$id]);
}
Im Test schickte ein Client erst ab, später den Rest der Zeile. Der Server antwortete erst auf die vollständige Zeile abc, und ein zweiter Client wurde in der Zwischenzeit sofort bedient.
Die zweite Schwäche ist eine Zeile ohne Ende. Ohne die Grenze von 4 KiB wächst der Puffer, solange ein Client sendet. Im Test schickte ein Client 64 MiB ohne Zeilenumbruch, und der Server brach für alle ab, mit Allowed memory size of 134217728 bytes exhausted. Mit der Grenze wurde nur dieser Client getrennt. Ein fehlerhafter Scanner genügt für so einen Ausfall, ein Angreifer erst recht. Aus demselben Grund bekommt fgets() in den blockierenden Beispielen die Länge 4096 mit.
Wer lieber blockierend arbeitet, begrenzt das Warten mit stream_set_timeout($conn, 1). Die Zeilenfalle löst das aber nicht: Mit der halben Zeile abc im Puffer lieferte fgets() im Test nach einer Sekunde 'abc', als wäre die Zeile vollständig, und stream_get_meta_data() meldete timed_out als true. Nur ohne jede Eingabe kam false. Wer so arbeitet, prüft nach jedem Lesen timed_out und verwirft die Zeile, wenn es gesetzt ist.
UDP und Unix Domain Sockets
Sensoren und Syslog-Sender schicken ihre Werte oft per UDP. Dort gibt es keine Verbindung und deshalb auch kein Lauschen, jedes Datagramm steht für sich. PHP stream_socket_server() bekommt für UDP nur STREAM_SERVER_BIND, mit den Standard-Flags scheitert der Start an der nichtssagenden Meldung aus der Tabelle. Die Datagramme liest stream_socket_recvfrom():
<?php
$server = stream_socket_server(
'udp://127.0.0.1:5140',
$errno,
$errstr,
STREAM_SERVER_BIND
);
while (true) {
$paket = stream_socket_recvfrom(
$server, 1500, 0, $absender
);
if ($paket === false) {
continue;
}
stream_socket_sendto(
$server, "OK\n", 0, $absender
);
}
Im Test kam jedes Paket mit Absenderadresse an, und die Antwort erreichte den Client. stream_socket_accept() ist hier fehl am Platz und scheitert. Auf die Puffergröße kommt es an: Bei einer Länge von 3 kam aus ping 2 nur pin an, und der Rest des Datagramms war verloren.
Sprechen nur Prozesse auf demselben Linux-Server miteinander, braucht es keinen Port. Mit unix:// legt PHP stream_socket_server() einen Unix Domain Socket an, eine Datei, deren Rechte den Zugriff regeln. Die Transportliste aus stream_get_transports() zeigt unter Linux zusätzlich unix und udg, unter Windows mit PHP 8.4 fehlen beide. Dort endet der Versuch mit Unable to find the socket transport "unix".
Nach fclose() bleibt die Socket-Datei liegen, und der nächste Start scheiterte mit Unknown error, bis die Datei gelöscht war. Nach einem Absturz bleibt sie ebenso zurück, deshalb gehört das Aufräumen an den Anfang:
<?php
$pfad = '/run/lager/scanner.sock';
/* antwortet dort noch ein Server? */
$probe = @stream_socket_client("unix://$pfad");
if ($probe !== false) {
exit("Auf dem Socket läuft schon ein Server\n");
}
if (file_exists($pfad)) {
unlink($pfad);
}
$server = stream_socket_server("unix://$pfad");
if ($server === false) {
/* $errstr bliebe hier leer, die Warnung zählt */
exit(1);
}
Ohne diese Probe löschte ein zweiter Start im Test die Datei eines laufenden Servers, und der erste bekam danach keinen Client mehr. Der Peername einer Unix-Verbindung war im Test ein leerer String und taugt nicht zur Auswertung.
TLS-Server mit eigenem Zertifikat
Für verschlüsselte Verbindungen bekommt der Server ein Zertifikat samt Schlüssel, beide dürfen in einer PEM-Datei stehen. Für Tests reicht ein selbst signiertes Zertifikat, erzeugt mit openssl req oder mit den openssl_*-Funktionen von PHP. Danach braucht PHP stream_socket_server() nur ein anderes Schema und einen Kontext:
<?php
/* begrenzt auch die Dauer des Handshakes */
ini_set('default_socket_timeout', '5');
$kontext = stream_context_create([
'ssl' => [
'local_cert' => __DIR__ . '/server.pem',
],
]);
$server = stream_socket_server(
'tls://127.0.0.1:8443',
$errno,
$errstr,
STREAM_SERVER_BIND | STREAM_SERVER_LISTEN,
$kontext
);
Der Handshake findet in stream_socket_accept() statt. Ein Client ohne passende Zertifizierungsstelle scheiterte mit certificate verify failed, während der Server tlsv1 alert unknown ca meldete. Mit dem Zertifikat als cafile und localhost als peer_name stand die Verbindung mit TLSv1.3.
Ein Client, der sich verbindet und dann schweigt, blockierte stream_socket_accept($server, 3) unter Linux für 60,05 Sekunden, mit der Meldung SSL: Handshake timed out. Der Timeout-Parameter zählt beim Handshake nicht, maßgeblich ist default_socket_timeout. So lange steht auch jede Select-Schleife still, deshalb setzt das Beispiel den Wert herunter. Dasselbe gilt, wenn PHP stream_socket_server() tcp:// bindet und die Verbindung erst nach dem Annehmen hochstuft, wie bei STARTTLS: Blockierend wartete stream_socket_enable_crypto() im Test 5,0 Sekunden auf den schweigenden Client, bei einem default_socket_timeout von 5. Ohne Stillstand geht es nur nicht blockierend:
<?php
/* Server-Kontext wie oben, Adresse tcp:// */
stream_set_blocking($conn, false);
$ok = stream_socket_enable_crypto(
$conn,
true,
STREAM_CRYPTO_METHOD_TLS_SERVER
);
/* 0: Handshake läuft noch, in der nächsten
Runde erneut aufrufen; true: TLS steht */
Im Test kam sofort 0 zurück, und mit einem echten Client stand die Verbindung nach drei Aufrufen in aufeinanderfolgenden Runden mit TLSv1.3.
Die Clientseite dieses Musters, samt SMTP und den Fehlercodes von OpenSSL, steht im Tutorial zu stream_socket_enable_crypto().
Grenzen von PHP stream_socket_server()
Die Schleife läuft in einem einzigen Prozess. Braucht eine Antwort zwei Sekunden Rechenzeit, warten alle anderen Clients zwei Sekunden. stream_select() nutzt unter Linux den Systemaufruf select(), der meist bei 1024 offenen Verbindungen endet. Darüber lieferte stream_select() im Test false mit dem Hinweis You MUST recompile PHP with a larger value of FD_SETSIZE. Die Schleife oben bräche dann mit break ab und beendete den ganzen Dienst, deshalb lehnt sie ab 500 Clients neue Verbindungen ab. Und stürzt der Prozess ab, startet ihn niemand neu, das übernehmen systemd oder ein Prozessmanager.
Für einen internen Dienst mit einigen Dutzend Geräten reicht PHP stream_socket_server() mit dieser Schleife völlig. Bei Hunderten gleichzeitiger Verbindungen oder bei Browsern als Gegenstelle sind ereignisgesteuerte Bibliotheken wie ReactPHP oder AMPHP die bessere Wahl. Für Echtzeitdaten im Browser zeigt das Tutorial WebSocket mit PHP den Weg über Ratchet.
Fazit
PHP stream_socket_server() bindet einen Port, und stream_socket_accept() macht aus jedem Client einen eigenen Stream. Für mehrere Clients sorgt stream_select() mit dem Server-Socket im selben Array, und der nicht blockierende Modus mit begrenztem Zeilenpuffer verhindert, dass eine halbe oder endlose Zeile den Dienst anhält.
Die meisten Stolperstellen von PHP stream_socket_server() zeigen sich beim Start: localhost band auf beiden Testsystemen nur IPv6, $errno bleibt bei Bindefehlern 0, Windows meldet einen belegten Port gar nicht, und UDP braucht STREAM_SERVER_BIND allein. Beim TLS-Server entscheidet default_socket_timeout, wie lange ein schweigender Client alles blockiert, solange der Handshake blockierend läuft. Wer diese Punkte im Startcode abfängt, hat einen Server, der ehrlich scheitert statt still ins Leere zu laufen.