How to Build an AI Chatbot with PHP (Step-by-Step, 2026)

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/client

Step 2: Project Structure

chatbot/
├── vendor/
├── index.html       # Chat UI
├── chat.php         # API endpoint (non-streaming)
├── stream.php       # API endpoint (streaming SSE)
└── .env             # API key

Step 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_KEY as 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.php returns JSON after the full response is ready
  • Streaming: stream.php uses 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 ErrorException and TransporterException in production

Subscribe to my newsletter — practical guides on Claude API, AI agents, RAG, and automation.

Subscribe