Be part of JetBrains PHPverse 2026 on June 9 – a free online event bringing PHP devs worldwide together.

ramondrent's avatar

Using OpenAI streaming with Livewire to update the front-end

Hi everyone!

I'm getting stuck on (hopefully) the last missing piece, I hope somebody knows what i'm missing. I'm using the OpenAI-PHP/Laravel package and I want to implement the new streaming functionality to update the front-end (normal Blade + Livewire) as the words are coming in.

It works when I'm testing it out inside a route, like so:

Route::get('/completion-stream', function () {
	$stream = OpenAI::chat()->createStreamed([
		"model" => "gpt-3.5-turbo",
		'messages' => [
			['role' => 'user', 'content' => 'PHP is '],
		],
	]);
	return response()->stream(function () use ($stream) {
		foreach ($stream as $response) {
			echo $response->choices[0]->delta->content;
			ob_flush();
			flush();
		}
	},
	200,
	['X-Accel-Buffering' => 'no']);
});

But I haven't had much luck getting this to work after clicking a button in Livewire. This is my Livewire component:

<div>
	<button wire:click="generateDescription">Generate description</button>

	<div x-data="{ reply: '' }" @description-generated.window="reply = $event.detail.reply">
		<p class="description" x-text="reply"></p>
	</div>
</div>

And this is my Livewire class:

<?php

namespace App\Http\Livewire;

use Livewire\Component;
use OpenAI\Laravel\Facades\OpenAI;

class DescriptionGenerator extends Component
{
	public function generateDescription()
	{		
		$reply = '';
		$stream = OpenAI::chat()->createStreamed([
			"model" => "gpt-3.5-turbo",
			'messages' => [
				['role' => 'user', 'content' => 'PHP is '],
			],
		]);

		foreach ($stream as $response) {
			$reply .= $response->choices[0]->delta->content;
			$this->dispatchBrowserEvent('description-generated', ['reply' => $reply]);
		};
	}

    public function render()
    {
        return view('livewire.description-generator');
    }
}

The method is getting called when clicking the button, and it updates the browser after it gets the complete answer from OpenAI, it just doesn't give me stream word for word. As you can see I've already tried dispatching a browser event, but still no luck.

Thanks!

0 likes
8 replies
LaryAI's avatar
Level 58

The issue with the current implementation is that the foreach loop in the generateDescription method is blocking the Livewire component from updating the front-end until the entire response is received from OpenAI. To update the front-end as the words are coming in, the foreach loop needs to be run in a separate thread. This can be achieved using PHP's pcntl_fork function.

Here's an updated version of the Livewire component and class:

Livewire component:

<div>
    <button wire:click="generateDescription">Generate description</button>

    <div x-data="{ reply: '' }" x-init="listenForDescriptionGeneratedEvent()">
        <p class="description" x-text="reply"></p>
    </div>
</div>

@push('scripts')
    <script>
        function listenForDescriptionGeneratedEvent() {
            window.livewire.on('description-generated', ({ reply }) => {
                document.querySelector('.description').textContent = reply;
            });
        }
    </script>
@endpush

Livewire class:

<?php

namespace App\Http\Livewire;

use Livewire\Component;
use OpenAI\Laravel\Facades\OpenAI;

class DescriptionGenerator extends Component
{
    public function generateDescription()
    {
        $stream = OpenAI::chat()->createStreamed([
            "model" => "gpt-3.5-turbo",
            'messages' => [
                ['role' => 'user', 'content' => 'PHP is '],
            ],
        ]);

        $pid = pcntl_fork();

        if ($pid == -1) {
            // Fork failed
            $this->dispatchBrowserEvent('description-generated', ['reply' => 'Error: Fork failed']);
        } elseif ($pid) {
            // Parent process
            pcntl_wait($status); // Wait for child process to finish
        } else {
            // Child process
            foreach ($stream as $response) {
                $reply = $response->choices[0]->delta->content;
                $this->dispatchBrowserEvent('description-generated', ['reply' => $reply]);
            }
            exit(); // Exit child process
        }
    }

    public function render()
    {
        return view('livewire.description-generator');
    }
}

Explanation:

  • The generateDescription method now forks a child process to run the foreach loop in the background.
  • The listenForDescriptionGeneratedEvent method is called when the Livewire component is initialized. It listens for the description-generated event and updates the front-end with the new reply.
  • The dispatchBrowserEvent method is called inside the child process to send the description-generated event to the front-end with the new reply.
ramondrent's avatar

@LaryAI Unfortunately trying out this code, there is no text rendered at the front-end. Pretty useful still!

ramondrent's avatar

@veeqtoh Unfortunately not, I've kind of moved on from the project. I've read in the past that it may have something to do with the development server, and that it may just work if you use Apache, not sure though. Good luck!

giltotherescue's avatar

I finally got this working using livewire:stream.

My case was a little bit more complex because I am using it to generate a preview of text that the user can then review before deciding to use.

If the user generated a preview, then changed the configuration and generated a second preview, I needed to replace the contents of previewText. Initially I tried doing this with a simple $this->previewText = null, however that did not work due to the Laravel lifecycle not completing in time to render that update to the frontend immediately. The trick was to use the stream itself to reset the contents.

Here is what I ended up with in my view:

<form wire:submit="generatePreview();">
    <button type="submit"  {{ empty($previewText) ? 'disabled' : '' }}>Generate</button>
</form>

<div class="whitespace-pre-wrap" wire:stream="previewText">{{ $previewText }}</div>

And my Livewire component:

public function generatePreview() {
    // Clear previewText when generating a second preview
    $this->stream(to: "previewText", content: '', replace: true);

    $generate = new GenerateDraft(function($chunk) {
        if (
            isset($chunk['choices']) && 
            !isset($chunk['choices'][0]['delta']['role']) && 
            isset($chunk['choices'][0]['delta']['content'])
        ) {
            $this->stream(to: "previewText", content: $chunk['choices'][0]['delta']['content']);
        }
    });

    $this->previewText = $generate->stream();
}

Here is the relevant OpenAI bit from GenerateDraft:

public function createStream(string $prompt, callable $streamedCallback) {
    $streamedResponse = OpenAI::chat()->createStreamed($prompt);
            
    foreach ($streamedResponse as $chunk) {
        // Collect each chunk
        $streamedData[] = $chunk;

        // Call the provided callback function to broadcast each chunk
        $streamedCallback($chunk);
    }

    // Return the full response once the stream is complete
    $response = $this->processStreamedData($streamedData);
}

private function processStreamedData($streamedData) {
    $responseText = '';

    foreach ($streamedData as $chunk) {
        if (
            isset($chunk['choices']) && 
            !isset($chunk['choices'][0]['delta']['role']) && 
            isset($chunk['choices'][0]['delta']['content'])
        ) {
            $responseText .= $chunk['choices'][0]['delta']['content'];
        }
    }
       
    $response = [
        'choices' => [
            [
                'streamed' => true,
                'finish_reason' => 'stop',
                'message' => [
                    'content' => $responseText
                ]
            ]
        ]
    ];

    // Return the concatenated result
    return $response;
}

Funny how it took an entire day to wrap my head around the streaming concept, but the solution ended up being so simple.

ramondrent's avatar

Hi @giltotherescue, thanks so much for your extensive answer! I'm gonna take a look at it and see if I can implement it in my own code.

Please or to participate in this conversation.