Building an AI chatbot with PHP requires three things: a backend that talks to the OpenAI API, session-based conversation history, and a simple frontend that feels responsive. This guide builds exactly that — a working web chatbot from scratch using plain PHP, with a streaming variant that shows tokens as they arrive.
This guide uses the OpenAI API. For the full SDK reference, see OpenAI PHP SDK Guide. For a Laravel-specific implementation, see Laravel OpenAI Integration.
Prerequisites
- PHP 8.1+
- Composer
- OpenAI API key
- A web server (Apache, Nginx, or
php -S localhost:8000)
Step 1: Install the SDK
composer require openai-php/clientStep 2: Project Structure
chatbot/
├── vendor/
├── index.html # Chat UI
├── chat.php # API endpoint (non-streaming)
├── stream.php # API endpoint (streaming SSE)
└── .env # API keyStep 3: The Chat Backend
Create chat.php — this handles a single message and returns JSON:
<?php
require 'vendor/autoload.php';
session_start();
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit(json_encode(['error' => 'Method not allowed']));
}
$body = json_decode(file_get_contents('php://input'), true);
$input = trim($body['message'] ?? '');
if ($input === '') {
http_response_code(400);
exit(json_encode(['error' => 'Empty message']));
}
// Load API key
$apiKey = getenv('OPENAI_API_KEY') ?: (require __DIR__ . '/config.php')['openai_key'];
// Init conversation history in session
if (!isset($_SESSION['history'])) {
$_SESSION['history'] = [
['role' => 'system', 'content' => 'You are a helpful assistant. Be concise.'],
];
}
// Append user message
$_SESSION['history'][] = ['role' => 'user', 'content' => $input];
// Keep only last 20 messages + system prompt to avoid token overflow
$messages = array_merge(
[array_shift($_SESSION['history'])], // system prompt always first
array_slice($_SESSION['history'], -20)
);
try {
$client = OpenAI::client($apiKey);
$response = $client->chat()->create([
'model' => 'gpt-4o-mini',
'messages' => $messages,
'max_tokens' => 1024,
]);
$reply = $response->choices[0]->message->content;
// Append assistant reply to history
$_SESSION['history'][] = ['role' => 'assistant', 'content' => $reply];
echo json_encode(['reply' => $reply]);
} catch (\OpenAI\Exceptions\ErrorException $e) {
http_response_code(503);
echo json_encode(['error' => 'API error: ' . $e->getMessage()]);
} catch (\Exception $e) {
http_response_code(500);
echo json_encode(['error' => 'Server error']);
}Step 4: Streaming Backend
Create stream.php — streams tokens via Server-Sent Events (SSE):
<?php
require 'vendor/autoload.php';
session_start();
// SSE headers
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('X-Accel-Buffering: no');
$body = json_decode(file_get_contents('php://input'), true);
$input = trim($body['message'] ?? '');
if ($input === '') {
echo "data: " . json_encode(['error' => 'Empty message']) . "\n\n";
exit;
}
if (!isset($_SESSION['history'])) {
$_SESSION['history'] = [
['role' => 'system', 'content' => 'You are a helpful assistant.'],
];
}
$_SESSION['history'][] = ['role' => 'user', 'content' => $input];
$client = OpenAI::client(getenv('OPENAI_API_KEY'));
$stream = $client->chat()->createStreamed([
'model' => 'gpt-4o-mini',
'messages' => $_SESSION['history'],
]);
$fullReply = '';
foreach ($stream as $response) {
$delta = $response->choices[0]->delta->content;
if ($delta !== null) {
$fullReply .= $delta;
echo "data: " . json_encode(['chunk' => $delta]) . "\n\n";
ob_flush();
flush();
}
}
// Save complete reply to session history
$_SESSION['history'][] = ['role' => 'assistant', 'content' => $fullReply];
echo "data: [DONE]\n\n";Step 5: The Chat Frontend
Create index.html — a clean chat interface:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PHP AI Chatbot</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f0f2f5; height: 100vh; display: flex;
align-items: center; justify-content: center; }
.chat-container { width: 100%; max-width: 700px; height: 90vh;
background: #fff; border-radius: 12px;
box-shadow: 0 4px 20px rgba(0,0,0,.12);
display: flex; flex-direction: column; }
.chat-header { padding: 16px 20px; border-bottom: 1px solid #eee;
font-weight: 600; font-size: 18px; }
.messages { flex: 1; overflow-y: auto; padding: 20px; display: flex;
flex-direction: column; gap: 12px; }
.msg { max-width: 75%; padding: 10px 14px; border-radius: 12px;
line-height: 1.5; font-size: 14px; white-space: pre-wrap; }
.msg.user { background: #1a56db; color: #fff; align-self: flex-end;
border-bottom-right-radius: 4px; }
.msg.bot { background: #f1f3f5; color: #111; align-self: flex-start;
border-bottom-left-radius: 4px; }
.input-area { padding: 16px; border-top: 1px solid #eee;
display: flex; gap: 10px; }
#msgInput { flex: 1; padding: 10px 14px; border: 1px solid #ddd;
border-radius: 20px; font-size: 14px; outline: none; }
#msgInput:focus { border-color: #1a56db; }
#sendBtn { padding: 10px 20px; background: #1a56db; color: #fff;
border: none; border-radius: 20px; font-size: 14px;
cursor: pointer; transition: background .2s; }
#sendBtn:hover { background: #1e429f; }
#sendBtn:disabled { background: #93a4d3; cursor: not-allowed; }
</style>
</head>
<body>
<div class="chat-container">
<div class="chat-header">AI Assistant</div>
<div class="messages" id="messages">
<div class="msg bot">Hello! How can I help you today?</div>
</div>
<div class="input-area">
<input id="msgInput" type="text" placeholder="Type a message..." autocomplete="off">
<button id="sendBtn">Send</button>
</div>
</div>
<script>
const messagesEl = document.getElementById('messages');
const inputEl = document.getElementById('msgInput');
const sendBtn = document.getElementById('sendBtn');
function addMsg(role, text) {
const d = document.createElement('div');
d.className = 'msg ' + role;
d.textContent = text;
messagesEl.appendChild(d);
messagesEl.scrollTop = messagesEl.scrollHeight;
return d;
}
async function send() {
const text = inputEl.value.trim();
if (!text) return;
inputEl.value = '';
sendBtn.disabled = true;
addMsg('user', text);
const botMsg = addMsg('bot', '...');
try {
// Use stream.php for streaming, or chat.php for non-streaming
const res = await fetch('stream.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: text }),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
botMsg.textContent = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6);
if (data === '[DONE]') break;
try {
const parsed = JSON.parse(data);
if (parsed.chunk) {
botMsg.textContent += parsed.chunk;
messagesEl.scrollTop = messagesEl.scrollHeight;
}
} catch {}
}
}
} catch (err) {
botMsg.textContent = 'Error: ' + err.message;
} finally {
sendBtn.disabled = false;
inputEl.focus();
}
}
sendBtn.addEventListener('click', send);
inputEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
});
</script>
</body>
</html>Clearing Conversation History
Add a reset endpoint so users can start a fresh conversation:
// reset.php
<?php
session_start();
$_SESSION['history'] = [];
echo json_encode(['ok' => true]);In the frontend, add a Clear button:
document.getElementById('clearBtn').addEventListener('click', async () => {
await fetch('reset.php', { method: 'POST' });
messagesEl.innerHTML = '<div class="msg bot">Conversation cleared.</div>';
});Adding a System Prompt
Control the bot’s persona by changing the system message in chat.php:
$_SESSION['history'] = [
[
'role' => 'system',
'content' => 'You are a customer support agent for AcmeCorp. '
. 'Only answer questions about our products. '
. 'If asked about anything else, politely redirect.',
],
];Deploying to Production
- Set
OPENAI_API_KEYas a server environment variable — never hardcode it - Use HTTPS — SSE and session cookies require a secure connection in production
- Enable PHP sessions with a secure store (Redis/database) for multi-server deployments
- Add rate limiting per user to control API costs (e.g. 20 messages/minute)
- Log errors to a file, not to the browser response
Summary
- Non-streaming:
chat.phpreturns JSON after the full response is ready - Streaming:
stream.phpuses SSE to push tokens as they arrive - Conversation history lives in
$_SESSION['history']— trim it to avoid token overflow - The same approach works in Laravel — see Laravel OpenAI Integration
- Always catch
ErrorExceptionandTransporterExceptionin production
Subscribe to my newsletter — practical guides on Claude API, AI agents, RAG, and automation.