If you built a chatbot on OpenAI's Assistants API, it stopped working on August 26, 2026. The replacement is a pair of APIs that work together:
the Responses API generates replies, and
the Conversations API stores the thread on OpenAI's side, so you don't have to resend the whole history yourself.
In this tutorial, we'll build a persistent, multi-user chat in Laravel using the community-maintained openai-php/laravel package with streaming and tests.
Also Read: Programming and Development
What you'll build: users can start chats, send messages, see history, and stream replies token by token.
Requirements: PHP 8.2+, Laravel 11/12/13, an OpenAI API key.
Also Read: Carbon Immutable vs Mutable in PHP Laravel: Which Should You Use?
How the Conversations API works (in 60 seconds)
A conversation is just a container for items, messages, tool calls, and tool results. It has no model attached.
You create a conversation once → you get an ID like conv_abc123 .
Every time the user sends a message, you call responses()->create() with a model , the new input , and 'conversation' => 'conv_abc123' .
OpenAI automatically prepends earlier items to the request and appends the new user message and reply to the conversation.
Two things to know:
Conversation items don't expire. Standalone Response objects are kept for 30 days by default, but anything attached to a conversation is persisted with no 30-day TTL.
You still pay for history. Earlier turns are billed as input tokens on every request. Use truncation (below) for long chats.
Prefer something simpler? You can chain turns with previous_response_id instead of creating a conversation. Conversations are better when you want a stable ID to store per chat.
Step 1: Install openai-php/laravel
composer require openai-php/laravel
php artisan openai:installAdd your key to .env :
OPENAI_API_KEY=sk-...
OPENAI_MODEL=gpt-5.6-terraAnd register the model in config/services.php so it's easy to change:
'openai' => [
'model' => env('OPENAI_MODEL', 'gpt-5.6-terra'),
],Which model? GPT-5.6 Terra is a good balanced default. Use gpt-5.6-luna for cheap, high-volume chat and gpt-5.6-sol for harder reasoning.
Also Read: Meta Muse Zero-Day: How a Researcher Hijacked Meta's AI Agent, and What It Means for You
Step 2: Create a Chat model and migration
We'll store one row per chat, holding the OpenAI conversation ID.
php artisan make:model Chat -m// database/migrations/xxxx_create_chats_table.php
public function up(): void
{
Schema::create('chats', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('openai_conversation_id')->unique();
$table->string('title')->nullable();
$table->timestamps();
});
}// app/Models/Chat.php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Chat extends Model
{
use HasFactory;
protected $fillable = ['user_id', 'openai_conversation_id', 'title'];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}php artisan migrateStep 3: Build a ChatService
Keep all OpenAI calls in one service class so controllers stay thin and tests are easy.
// app/Services/ChatService.php
namespace App\Services;
use App\Models\Chat;
use App\Models\User;
use OpenAI\Laravel\Facades\OpenAI;
class ChatService
{
private const INSTRUCTIONS = 'You are a friendly assistant for TheWebTier readers. '
. 'Answer web development questions clearly, with short code examples when useful.';
/**
* Create a new conversation on OpenAI and a local Chat record.
*/
public function start(User $user, ?string $title = null): Chat
{
$conversation = OpenAI::conversations()->create([
'metadata' => [
'user_id' => (string) $user->id,
'app' => 'thewebtier',
],
]);
return Chat::create([
'user_id' => $user->id,
'openai_conversation_id' => $conversation->id,
'title' => $title,
]);
}
/**
* Send a message and get the full reply back.
*/
public function send(Chat $chat, string $message): string
{
$response = OpenAI::responses()->create([
'model' => config('services.openai.model'),
'conversation' => $chat->openai_conversation_id,
'instructions' => self::INSTRUCTIONS,
'input' => $message,
'truncation' => 'auto',
]);
$chat->touch();
return $response->outputText;
}
/**
* Fetch the conversation history from OpenAI.
*
* @return array<int, array{role: string, text: string}>
*/
public function history(Chat $chat, int $limit = 50): array
{
$items = OpenAI::conversations()->items()->list($chat->openai_conversation_id, [
'limit' => $limit,
'order' => 'asc',
])->toArray();
return collect($items['data'] ?? [])
->where('type', 'message')
->map(fn (array $item) => [
'role' => $item['role'],
'text' => collect($item['content'] ?? [])
->whereIn('type', ['input_text', 'output_text'])
->pluck('text')
->implode("\n"),
])
->values()
->all();
}
/**
* Delete the conversation on OpenAI and locally.
*/
public function delete(Chat $chat): void
{
OpenAI::conversations()->delete($chat->openai_conversation_id);
$chat->delete();
}
}Why pass instructions every time? Instructions apply to the current response only, they aren't stored as part of the conversation. Sending them on each call keeps behaviour consistent and lets you change your system prompt without migrating old chats.
Also Read: News and Tooling
Why truncation: auto ? If a chat grows past the model's context window, OpenAI drops the oldest items instead of failing the request.
Step 4: Controller and routes
php artisan make:controller ChatController// app/Http/Controllers/ChatController.php
namespace App\Http\Controllers;
use App\Models\Chat;
use App\Services\ChatService;
use Illuminate\Http\Request;
class ChatController extends Controller
{
public function __construct(private ChatService $chats) {}
public function store(Request $request)
{
$chat = $this->chats->start($request->user(), $request->input('title'));
return response()->json(['id' => $chat->id], 201);
}
public function show(Request $request, Chat $chat)
{
abort_unless($chat->user_id === $request->user()->id, 403);
return response()->json([
'chat' => $chat,
'messages' => $this->chats->history($chat),
]);
}
public function message(Request $request, Chat $chat)
{
abort_unless($chat->user_id === $request->user()->id, 403);
$validated = $request->validate([
'message' => ['required', 'string', 'max:8000'],
]);
$reply = $this->chats->send($chat, $validated['message']);
return response()->json(['reply' => $reply]);
}
public function destroy(Request $request, Chat $chat)
{
abort_unless($chat->user_id === $request->user()->id, 403);
$this->chats->delete($chat);
return response()->noContent();
}
}// routes/web.php (or api.php)
use App\Http\Controllers\ChatController;
Route::middleware(['auth', 'throttle:30,1'])->group(function () {
Route::post('/chats', [ChatController::class, 'store']);
Route::get('/chats/{chat}', [ChatController::class, 'show']);
Route::post('/chats/{chat}/messages', [ChatController::class, 'message']);
Route::post('/chats/{chat}/stream', [ChatController::class, 'stream']);
Route::delete('/chats/{chat}', [ChatController::class, 'destroy']);
});Security note: always check chat ownership (or use a Policy). A conversation ID is effectively a key to a user's history.
Also Read: News: OpenAI Says Its
Step 5: Stream replies (ChatGPT-style typing)
Waiting several seconds for a full reply feels slow. Streaming sends tokens as they're generated. Add this to ChatService :
/**
* Stream a reply, yielding text deltas as they arrive.
*/
public function stream(Chat $chat, string $message): \Generator
{
$stream = OpenAI::responses()->createStreamed([
'model' => config('services.openai.model'),
'conversation' => $chat->openai_conversation_id,
'instructions' => self::INSTRUCTIONS,
'input' => $message,
'truncation' => 'auto',
]);
foreach ($stream as $event) {
if ($event->event === 'response.output_text.delta') {
yield $event->response->delta;
}
}
$chat->touch();
}And to the controller, returning Server-Sent Events:
public function stream(Request $request, Chat $chat)
{
abort_unless($chat->user_id === $request->user()->id, 403);
$message = $request->validate(['message' => 'required|string|max:8000'])['message'];
return response()->stream(function () use ($chat, $message) {
foreach ($this->chats->stream($chat, $message) as $delta) {
echo 'data: ' . json_encode(['delta' => $delta]) . "\n\n";
ob_flush();
flush();
}
echo "data: [DONE]\n\n";
ob_flush();
flush();
}, 200, [
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache',
'X-Accel-Buffering' => 'no', // disables Nginx buffering
]);
}On the front end, read the stream with fetch() :
const res = await fetch(`/chats/${chatId}/stream`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
},
body: JSON.stringify({ message }),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
for (const line of decoder.decode(value).split('\n')) {
if (!line.startsWith('data: ') || line === 'data: [DONE]') continue;
output.textContent += JSON.parse(line.slice(6)).delta;
}
}Step 6: Test without hitting the API
openai-php/laravel ships a fake, so your test suite never spends tokens.
// tests/Feature/ChatServiceTest.php
use App\Models\Chat;
use App\Services\ChatService;
use OpenAI\Laravel\Facades\OpenAI;
use OpenAI\Resources\Responses;
use OpenAI\Responses\Responses\CreateResponse;
it('sends messages inside the stored conversation', function () {
OpenAI::fake([
CreateResponse::fake(),
]);
$chat = Chat::factory()->create(['openai_conversation_id' => 'conv_test123']);
app(ChatService::class)->send($chat, 'What is a service container?');
OpenAI::assertSent(Responses::class, function (string $method, array $parameters) {
return $method === 'create'
&& $parameters['conversation'] === 'conv_test123'
&& $parameters['input'] === 'What is a service container?';
});
});Also Read: OpenAI's Model Lineup in September 2026: GPT-6 Astra, GPT-5.6 Sol/Terra/Luna & What's Retiring
Production tips
Queue long replies. For non-streamed replies, dispatch a job and broadcast the result with Laravel Reverb.
Store a local transcript too if you need search, analytics, or exports , OpenAI's copy isn't queryable from your database.
Add metadata (user ID, plan, locale) when creating conversations; it's searchable in the OpenAI dashboard.
Rate-limit per user with throttle middleware to control costs.
Delete on account deletion. Call conversations()->delete() when users delete chats or accounts to respect privacy obligations.
Migrating from Assistants? Map each old thread_... to a new conversation. Packages like halilcosdu/laravel-chatbot include a migration command.
Alternative: the Laravel AI SDK
If you'd rather keep conversation history in your own database and switch between OpenAI, Claude, Gemini and Grok with one line, Laravel's first-party AI SDK ( laravel/ai ) has a RemembersConversations trait that does this for you. We cover it in How to Use Claude, Gemini, Grok & Jev APIs in Laravel .
FAQ
Is the Conversations API the same as the Assistants API? No. The Assistants API was shut down on August 26, 2026. Conversations + Responses is its replacement, with simpler, stateless-by-default requests.
Do I choose the model when creating a conversation? No. Conversations only store items and metadata. You choose the model on each responses()->create() call , so you can even switch models mid-conversation.
Do conversations expire? Items attached to a conversation aren't subject to the 30-day retention that standalone responses have.
Does openai-php support the Conversations API? Yes , conversations() and conversations()->items() are supported in current versions.
