Streaming real-time data from APIs like Lichess using native PHP can be challenging. By default, PHP's cURL extension waits for the entire request to finish before returning the response. Because Lichess stream connections (like the Board Game Stream API) are kept open indefinitely, standard cURL execution will simply hang or timeout.

The Solution: Use CURLOPT_WRITEFUNCTION

To process stream data as it arrives, you must use the CURLOPT_WRITEFUNCTION option. This option allows you to define a callback function that handles incoming chunks of data in real-time. Since Lichess streams data in NDJSON (Newline Delimited JSON) format, you will also need to buffer the incoming chunks and split them by newlines to decode each JSON object individually.

Real-Time PHP Lichess Stream Parser

Here is a complete, native PHP solution that reads the Lichess stream, buffers incomplete chunks, ignores the 7-second keep-alive blank lines, and decodes each event as it happens:

<?php
// Configuration
$gameId = "your_game_id_here";
$token = "your_lichess_personal_token";
$url = "https://lichess.org/api/board/game/stream/{$gameId}";

$ch = curl_init($url);

// Buffer to store partial NDJSON lines across chunks
$buffer = '';

curl_setopt_array($ch, [
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer {$token}",
        "Accept: application/x-ndjson"
    ],
    // We handle the data output manually via the callback
    CURLOPT_RETURNTRANSFER => false, 
    CURLOPT_WRITEFUNCTION => function($ch, $data) use (&$buffer) {
        $buffer .= $data;
        
        // Split the buffer by newline characters
        $lines = explode("\n", $buffer);
        
        // The last element may be an incomplete JSON line. 
        // We pop it off and save it back to the buffer for the next chunk.
        $buffer = array_pop($lines);
        
        foreach ($lines as $line) {
            $line = trim($line);
            
            // Skip Lichess keep-alive blank lines (sent every 7 seconds)
            if (empty($line)) {
                continue;
            }
            
            // Parse the NDJSON line
            $event = json_decode($line, true);
            if (json_last_error() === JSON_ERROR_NONE) {
                // Handle your parsed real-time event here
                handleLichessEvent($event);
            }
        }
        
        // You MUST return the exact length of the data processed
        return strlen($data);
    }
]);

// Helper function to handle the parsed JSON event
function handleLichessEvent($event) {
    if (isset($event['type'])) {
        echo "[Event Type]: " . $event['type'] . PHP_EOL;
    }
    
    // Output immediately to the console/terminal
    if (ob_get_level() > 0) {
        ob_flush();
    }
    flush();
}

// Execute the stream (this will block and run continuously)
curl_exec($ch);

if (curl_errno($ch)) {
    echo 'cURL Error: ' . curl_error($ch);
}

curl_close($ch);
?>

How This Works Under the Hood

  • CURLOPT_WRITEFUNCTION: Instead of waiting for the connection to close, cURL triggers this callback every time new network packets arrive.
  • Chunk Buffer Management: Streams do not guarantee that packet boundaries align with newline characters. A JSON string might be split across two packets. Saving the last partial element using array_pop() ensures we never attempt to parse malformed, incomplete JSON.
  • Keep-Alive Filtering: Lichess keeps the HTTP stream alive by sending empty lines. Checking empty($line) prevents your script from attempting to decode empty strings.
  • Immediate Output: Calling flush() ensures that output is pushed directly to your terminal or output stream without being held back by PHP's internal buffering mechanisms.