Streaming GPT-3.5-turbo Responses

1395 views
Streaming GPT-3.5-turbo Responses

A lot of PHP streaming tutorials get 80% of the way there and then quietly ship a bug: they echo raw cURL chunks straight to the browser and call it done. It looks fine in a five-second demo, then breaks in production the moment a chunk arrives split across a JSON boundary — which happens far more than people expect, since TCP doesn’t care about your JSON structure.

This guide builds a streaming integration that actually holds up: correct line buffering, proper [DONE] handling, and a front end that renders only the text, not raw SSE payloads. It also flags something most guides from the last couple of years don’t: gpt-3.5-turbo is being retired. OpenAI has scheduled a hard shutdown for October 23, 2026, alongside GPT-4 and several o-series models. If you’re starting fresh, build this against gpt-4o-mini or a current GPT-5-tier model instead — the code below is model-agnostic, so swapping the string is all it takes.

How Streaming Actually Works

When you send “stream”: true in a Chat Completions request, OpenAI keeps the HTTP connection open and pushes a sequence of Server-Sent Events (SSE) as the model generates tokens. Each event looks like:

data: {"id":"chatcmpl-abc","choices":[{"delta":{"content":"Hello"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-abc","choices":[{"delta":{"content":" there"},"index":0,"finish_reason":null}]}

data: [DONE]

 

Stream GPT Responses in PHP Using the OpenAI API

Streaming mode lets you receive a ChatGPT response token-by-token (or word-by-word) instead of waiting for the full answer. In other words, when you set “stream”: true in your API call, OpenAI will send pieces of the message as they are generated, much like ChatGPT’s typing effect. This can improve user experience on long responses, since the user sees the answer appear in real time rather than waiting. As one OpenAI developer notes, streaming returns the “completion back word-after-word (like ChatGPT)”, whereas non-streaming returns the whole response at once. To enable this, you simply include ‘stream’ => true in your request payload. The API then returns an ongoing HTTP stream where each JSON chunk contains part of the assistant’s reply. Your PHP script can read these chunks incrementally and immediately output them to the client.

 

Securely Storing Your API Key

Before coding, make sure your OpenAI API key is stored securely. Do not hard-code the key in your PHP scripts. A best practice is to use an environment variable or a separate config file. For example, you might put your key in your server’s environment as OPENAI_API_KEY (or in an .env file) and then in PHP use:

 

$apiKey = getenv('OPENAI_API_KEY');

 

This way the secret stays out of your source code and version control. A StackOverflow answer warns that embedding keys in code “is just asking for people to impersonate you” and recommends environment variables instead.

 

Writing the PHP Script to Call the API

With your key ready, create a plain PHP script (e.g. stream.php). You only need PHP and its built-in cURL extension – no frameworks or extra libraries. Below is an outline of the PHP code to make a streaming API call:

 

<?php
// 1. Load API key from environment
$apiKey = getenv('OPENAI_API_KEY');

// 2. Build the request data with 'stream' => true
$data = [
    'model' => 'gpt-3.5-turbo',
    'messages' => [
        ['role' => 'user', 'content' => 'Hello, how are you?']
    ],
    'stream' => true,    // enable streaming mode
    'max_tokens' => 150
];

// 3. Initialize cURL
$ch = curl_init('https://api.openai.com/v1/chat/completions');
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Content-Type: application/json',
    'Authorization: Bearer ' . $apiKey,
    // If you use Server-Sent Events (see below), you can also add:
    // 'Accept: text/event-stream'
]);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));

// 4. Tell cURL to return the transfer as a stream
curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);

// 5. Define a write callback to handle each incoming chunk
curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($curl, $chunk) {
    // This function is called by cURL as soon as a new piece of data is available.
    echo $chunk;              // output the chunk immediately
    ob_flush();               // flush PHP output buffers
    flush();                  // flush web server output buffers
    return strlen($chunk);    // must return the number of bytes processed
});

// 6. Execute the request
curl_exec($ch);

// 7. Handle errors and cleanup
if (curl_errno($ch)) {
    // In case of error, print it (you may also log it)
    echo 'API Error: ' . curl_error($ch);
}
curl_close($ch);
?>

 

Code breakdown: We set up a POST to the /v1/chat/completions endpoint with our JSON data, including ‘stream’ => true. We use CURLOPT_WRITEFUNCTION so that cURL calls our callback each time a chunk arrives. Inside that callback, we echo the chunk and immediately flush the output (ob_flush(); flush();) so it goes straight to the browser. This way, each chunk (usually containing a piece of the assistant’s choices[0].delta.content) is sent to the client as soon as it arrives. In effect, the response is processed incrementally rather than all at once.

 

Handling the Stream in PHP

In streaming mode, OpenAI sends a series of JSON objects, each starting with data: (if using SSE) or just raw JSON if not. The PHP callback above simply echoes whatever arrives. For a simple output, you might see pieces of JSON like:

 

data: {"id":"...","choices":[{"delta":{"content":"Hello"},"index":0,"finish_reason":null}]}  
data: {"id":"...","choices":[{"delta":{"content":", how"},"index":0,"finish_reason":null}]}  
data: {"id":"...","choices":[{"delta":{"content":" are you?"},"index":0,"finish_reason":"stop"}]}  

 

Each echo $chunk will send that directly to the client. If you’re not using SSE on the client side (see below), you might need to remove the “data:” prefixes yourself. In any case, because we flush after each echo, the browser receives and can display the message piece by piece. This mimics a “typing” effect in the UI.

(Tip: Use set_time_limit(0); at the top if you expect long responses, to prevent PHP from timing out.)

 

Real-Time Display on the Web Page

To show the streamed content in the browser as it arrives, you have two common options: Server-Sent Events (SSE) with EventSource, or the Fetch API with Streams. Both let you append text to the page immediately.

 

Option 1: Server-Sent Events (EventSource)

Server-Sent Events is a one-way streaming protocol from server to client. To use it, have your PHP script output with an SSE-compatible header and format. For example, modify the PHP above to start with:

 

header("Content-Type: text/event-stream");
header("Cache-Control: no-cache");

 

And in your write callback (or loop) prefix each chunk with data: and two newlines. For example:

 

curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($curl, $chunk) {
    // Assume $chunk contains plain text or JSON without "data:" prefix.
    // Prepend "data:" so it forms a valid SSE message.
    echo "data: " . $chunk . "\n\n";
    ob_flush(); flush();
    return strlen($chunk);
});

 

This ensures the browser’s EventSource can read the stream. On the HTML/JS side, you would write:

<div id="chatOutput"></div>
<script>
// Create an EventSource listening to our PHP stream endpoint.
const source = new EventSource('stream.php?prompt=Hello');

// Append each received message to the page.
source.onmessage = function(event) {
    document.getElementById('chatOutput').innerText += event.data;
};
</script>

 

As chunks arrive, the onmessage handler fires with event.data containing the text. This uses the built-in EventSource mechanism and does not require external libraries. A PHP example of an SSE loop (outside of OpenAI) looks like:

<?php
header("Content-type: text/event-stream");
while (true) {
    echo "data: " . date("H:i:s") . "\n\n";
    ob_flush(); flush();
    sleep(1);
}
?>

 

which demonstrates sending data: lines to the client. By analogy, our OpenAI loop sends the AI’s content.

Option 2: Fetch with ReadableStream

If you prefer a more modern JS approach, you can use fetch() and read the response.body stream manually. For example:

<div id="chatOutput"></div>
<script>
async function streamOpenAI() {
    const response = await fetch('stream.php?prompt=Hello');
    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let done = false;
    let textChunk;

    while (!done) {
        // Read the next data chunk from the stream
        const { value, done: streamDone } = await reader.read();
        done = streamDone;
        if (value) {
            // Decode and append the text to the page
            textChunk = decoder.decode(value, { stream: true });
            document.getElementById('chatOutput').innerText += textChunk;
        }
    }
}

streamOpenAI();
</script>

 

Here we call the same PHP URL. As each value arrives, we decode it and update the page. This uses the JavaScript Streams API so the UI can update piece-by-piece without waiting. It does not require explicit “data:” formatting on the server (though if your PHP sends SSE format, you’d strip the data: prefix in JS). Note that using reader.read() in a loop like this lets us process the incoming bytes as soon as they come, similar to how EventSource works.

 

  • Enable streaming: Include “stream”: true in the JSON payload.

  • Secure your key: Store the API key in an environment variable or external file.

  • Use cURL with a write callback: Set CURLOPT_WRITEFUNCTION to echo each chunk and flush immediately.

  • Output to the client: Either format the output as SSE (text/event-stream) and use EventSource in JS, or fetch the stream and read it with the Streams API.

  • Flush promptly: Call ob_flush() and flush() after each echo so data is sent right away.

By following these steps, your PHP app can display GPT-3.5’s answer in real time as it’s generated. The user will see the text appear gradually, creating a smooth, interactive chat experience.

 

Two details matter here, and both get glossed over in quick tutorials:

  1. Each event is terminated by a blank line (\n\n), not just a newline. Your parser needs to buffer bytes until it sees a full event, not just echo bytes as they arrive.
  2. The stream ends with a literal data: [DONE] line — not an HTTP close you can rely on cleanly, and not JSON, so trying to json_decode() it will throw a silent failure if you don’t check for it first.

 

Step 1: Store the API Key Outside Your Code

This part hasn’t changed and shouldn’t be skipped: never hardcode a key in a PHP file that might end up in a repo, a backup, or a support ticket. Set it as a server environment variable (via your process manager, .env loader, or hosting panel) and read it with:

$apiKey = getenv('OPENAI_API_KEY');

if (!$apiKey) {
    http_response_code(500);
    exit('Server misconfiguration: missing API key.');
}

Failing loudly if the key is missing saves you a confusing debugging session later.

 

Step 2: A Streaming Endpoint with Correct SSE Parsing

Here’s the core script (stream.php). The key difference from a naive version: a buffer that accumulates raw bytes and only processes complete lines, so a chunk boundary landing mid-JSON never corrupts the output.

<?php
set_time_limit(0);
ignore_user_abort(true);

header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('X-Accel-Buffering: no'); // disables buffering on Nginx reverse proxies

$apiKey = getenv('OPENAI_API_KEY');
if (!$apiKey) {
    echo "data: " . json_encode(['error' => 'Missing API key']) . "\n\n";
    exit;
}

$prompt = isset($_GET['prompt']) ? trim($_GET['prompt']) : 'Hello!';

$payload = [
    'model' => 'gpt-4o-mini', // swap for your current model of choice
    'messages' => [
        ['role' => 'user', 'content' => $prompt],
    ],
    'stream' => true,
    'max_tokens' => 300,
];

$buffer = '';

$ch = curl_init('https://api.openai.com/v1/chat/completions');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => json_encode($payload),
    CURLOPT_HTTPHEADER => [
        'Content-Type: application/json',
        'Authorization: Bearer ' . $apiKey,
    ],
    CURLOPT_RETURNTRANSFER => false,
    CURLOPT_TIMEOUT => 0,
    CURLOPT_WRITEFUNCTION => function ($curl, $chunk) use (&$buffer) {
        $buffer .= $chunk;

        // Process only complete "data: ...\n\n" events from the buffer.
        while (($pos = strpos($buffer, "\n\n")) !== false) {
            $event  = substr($buffer, 0, $pos);
            $buffer = substr($buffer, $pos + 2);

            $line = trim(str_replace('data:', '', $event));
            if ($line === '' ) {
                continue;
            }
            if ($line === '[DONE]') {
                echo "data: [DONE]\n\n";
                @ob_flush();
                @flush();
                continue;
            }

            $decoded = json_decode($line, true);
            if (json_last_error() === JSON_ERROR_NONE && isset($decoded['choices'][0]['delta']['content'])) {
                $text = $decoded['choices'][0]['delta']['content'];
                // Re-emit as a clean SSE event containing just the text delta.
                echo "data: " . json_encode(['text' => $text]) . "\n\n";
                @ob_flush();
                @flush();
            }
        }

        return strlen($chunk);
    },
]);

curl_exec($ch);

if (curl_errno($ch)) {
    echo "data: " . json_encode(['error' => curl_error($ch)]) . "\n\n";
}

curl_close($ch);

What changed from a naive implementation, and why each change matters:

  • Byte buffering with strpos($buffer, “\n\n”) — guarantees you only act on a complete event, regardless of how cURL slices the underlying TCP stream.
  • Re-emitting clean JSON ({“text”: “…”}) instead of forwarding OpenAI’s raw event — the front end shouldn’t need to know OpenAI’s internal response shape at all. This also means you can switch providers later without touching client code.
  • X-Accel-Buffering: no — if you’re behind Nginx (common with PHP-FPM), Nginx buffers proxied responses by default, which silently defeats streaming even if your PHP code is correct. This header turns that off for the response.
  • ignore_user_abort(true) — lets you optionally detect and clean up if the visitor closes the tab mid-stream, rather than leaving a runaway process.

 

Step 3: Consuming the Stream in the Browser

With clean {“text”: “…”} events, the client side gets simpler, not more complex:

<div id="output"></div>
<script>
const output = document.getElementById('output');
const source = new EventSource('stream.php?prompt=' + encodeURIComponent('Explain closures in JavaScript'));

source.onmessage = function (event) {
    if (event.data === '[DONE]') {
        source.close();
        return;
    }
    const payload = JSON.parse(event.data);
    if (payload.error) {
        output.textContent += '\n[Error: ' + payload.error + ']';
        source.close();
        return;
    }
    output.textContent += payload.text;
};

source.onerror = function () {
    source.close();
};
</script>

 

Because the server already did the parsing work, the browser only ever deals with either plain text deltas or a clean error object — no leftover data: prefixes, no partial JSON to reassemble.

If You’d Rather Use fetch() Instead of EventSource

EventSource is simpler but GET-only and can’t set custom headers (a problem if you want to send auth tokens for your own backend, separate from the OpenAI key). If you need POST or headers, use fetch() with a ReadableStream reader and apply the same buffering logic in JavaScript:

async function streamChat(prompt) {
    const response = await fetch('stream.php', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ prompt }),
    });

    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let buffer = '';

    while (true) {
        const { value, done } = await reader.read();
        if (done) break;

        buffer += decoder.decode(value, { stream: true });

        let boundary;
        while ((boundary = buffer.indexOf('\n\n')) !== -1) {
            const rawEvent = buffer.slice(0, boundary);
            buffer = buffer.slice(boundary + 2);

            const line = rawEvent.replace(/^data:\s*/, '');
            if (line === '[DONE]') return;

            const payload = JSON.parse(line);
            document.getElementById('output').textContent += payload.text ?? '';
        }
    }
}

Same buffering principle as the PHP side — never assume a chunk lines up with a message boundary.

Handling Failures Gracefully

Streaming responses fail in ways a normal request doesn’t: the connection can drop mid-answer, the model can hit a content filter partway through, or your server’s own timeout can fire before the model finishes. Build for that from day one:

  • Client-side reconnect: EventSource auto-reconnects on network drops by default, but your PHP endpoint needs to be idempotent-safe (don’t re-charge a user’s usage quota) if a partial retry happens.
  • Track finish_reason in the delta payload — a value other than null or “stop” (like “content_filter” or “length”) tells you the response was cut short for a specific reason, worth surfacing to the user rather than silently truncating.
  • Timeouts: set_time_limit(0) disables PHP’s own timeout, but your web server (Nginx, Apache) and any load balancer in front of it have their own timeout settings that need raising separately for long-running streamed responses.

A Note on the Chat Completions vs. Responses API

Everything above uses the Chat Completions endpoint (/v1/chat/completions), which remains fully supported and is what most existing PHP integrations use. OpenAI has also introduced a newer Responses API aimed at agentic and multi-turn workflows with built-in tool state. If you’re building something more complex than single-turn chat — multi-step tool use, persistent conversation state managed server-side — it’s worth checking OpenAI’s current API documentation before committing, since that surface has moved fast over the past year.

  • Buffer raw bytes and split on \n\n — never assume a cURL chunk equals one SSE event.
  • Re-emit clean, minimal JSON to the client; don’t leak the provider’s raw response shape into your front end.
  • Set X-Accel-Buffering: no if you’re behind Nginx, or streaming will silently not stream.
  • Watch finish_reason, not just the text, so truncated or filtered responses don’t look like bugs.
  • gpt-3.5-turbo is on a retirement clock – point new projects at a current model from the start.

Get the buffering right and the rest of the streaming pipeline — PHP or otherwise — tends to fall into place.

 

Frequently Asked Questions

+

What is streaming in the OpenAI API?

Streaming allows the OpenAI API to send generated text incrementally instead of waiting for the complete response. This provides a faster, more interactive experience for users, especially in chat applications.
+

Why should I stream GPT responses in PHP?

Streaming reduces perceived latency, improves user experience, and makes AI-powered applications feel more responsive by displaying tokens as they are generated rather than after the entire response is complete.
+

Which PHP version is recommended for the OpenAI API?

PHP 8.0 or later is recommended because it offers better performance, improved error handling, and compatibility with modern libraries used for OpenAI API integrations.
+

How do I enable streaming in the OpenAI API?

Enable streaming by setting the stream parameter to true in your API request. Your PHP application then reads and processes each streamed response chunk as it arrives.
+

Does streaming reduce API costs?

No. Streaming changes how responses are delivered, not how they are billed. API charges are still based on the number of input and output tokens processed.
+

Can I stream responses in a chat application?

Yes. Streaming is ideal for AI chatbots, virtual assistants, customer support systems, coding assistants, and other real-time conversational interfaces.
+

Is cURL required to stream responses in PHP?

No. While cURL is commonly used, you can also use HTTP clients such as Guzzle or other PHP libraries that support streaming HTTP responses.
+

Can I stop a streaming response before it finishes?

Yes. Users can cancel the request from the client side, or your PHP application can terminate the connection if streaming is no longer needed.
+

What response format does the OpenAI streaming API use?

The API sends responses as Server-Sent Events (SSE), where each event contains a portion of the generated text until the completion signal is received.
+

Is streaming supported for all OpenAI models?

Streaming is supported by many chat-capable models, but availability depends on the specific API and model you're using. Always check the latest OpenAI documentation for current model capabilities.
+

Is it safe to expose the OpenAI API key in JavaScript?

No. API keys should always remain on the server. Your PHP backend should securely communicate with the OpenAI API, while the frontend interacts only with your server.
Previous Article

How to Add a Custom Section to WordPress Pages: The Complete Developer's Guide (Step-by-Step with Code)

Next Article

Build REST API in PHP Using GET, POST, PUT & DELETE

Write a Comment

Leave a Comment

Your email address will not be published. Required fields are marked *

Subscribe to our Newsletter

Subscribe to our email newsletter to get the latest posts delivered right to your email.
Pure inspiration, zero spam ✨