A job board gets the same career questions again and again: "How do I prepare for a Laravel interview?", "What should a fresher put on a resume?" An AI assistant can answer these around the clock. But a careless integration leaks your API key, burns money on repeated questions and breaks the moment the API is slow.
In this guide we will build a small, safe chat endpoint in Laravel. You will keep the API key on the server, call OpenAI through Laravel's HTTP client with a timeout and retry, cache repeated answers, validate and rate-limit user input, show the answer in a simple page, and test everything without spending a single token.
What we are building
One endpoint: POST /api/chat takes a question and returns an answer as JSON.
You need: Laravel 11 or newer, PHP 8.2 or newer, and an OpenAI API key. If your project has no routes/api.php yet, create it with:
php artisan install:api
Step 1: Keep the API key out of your code
Add these lines to .env:
OPENAI_API_KEY=your-secret-key-here
OPENAI_MODEL=put-a-current-model-name-here
Then register them in config/services.php:
'openai' => [
'key' => env('OPENAI_API_KEY'),
'model' => env('OPENAI_MODEL'),
'url' => 'https://api.openai.com/v1/chat/completions',
],
Three rules to follow here:
- The key lives only on the server. Never put it in JavaScript, a Blade view or a public repository. Make sure
.envis in.gitignore. - The model name lives in
.env, not in code. OpenAI adds and retires models regularly. Copy a current model name from OpenAI's documentation, and you can switch later without touching any PHP. - Read
env()only inside config files. Afterphp artisan config:cache, callingenv()elsewhere returns null. In the rest of the app, useconfig('services.openai.key').
Step 2: Write a small service class
Create app/Services/ChatService.php:
<?php
namespace App\Services;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use RuntimeException;
class ChatService
{
public function ask(string $question): string
{
// Same question (ignoring case and spaces) = same cache key
$cacheKey = 'chat:' . sha1(mb_strtolower(trim($question)));
return Cache::remember($cacheKey, now()->addHours(6), function () use ($question) {
return $this->callOpenAi($question);
});
}
private function callOpenAi(string $question): string
{
try {
$response = Http::withToken(config('services.openai.key'))
->timeout(30)
->retry(2, 500, throw: false)
->post(config('services.openai.url'), [
'model' => config('services.openai.model'),
'messages' => [
[
'role' => 'system',
'content' => 'You are an assistant for an IT job board. '
. 'Answer briefly about resumes, interviews and developer careers. '
. 'If a question is unrelated, politely say you cannot help with it.',
],
['role' => 'user', 'content' => $question],
],
'max_tokens' => 400,
]);
} catch (ConnectionException $e) {
Log::warning('OpenAI connection failed', ['error' => $e->getMessage()]);
throw new RuntimeException('The assistant is unreachable right now. Please try again.');
}
if ($response->failed()) {
Log::warning('OpenAI request failed', ['status' => $response->status()]);
throw new RuntimeException('The assistant could not answer right now. Please try again.');
}
return (string) $response->json('choices.0.message.content');
}
}
What each part is doing:
timeout(30)stops a slow API call from hanging your page forever.retry(2, 500, throw: false)makes up to two attempts with a 500 ms pause. Withthrow: falsewe get the last response back instead of an exception, so we can handle it ourselves.Cache::rememberstores the answer for six hours. If ten visitors ask the same thing, you pay for one API call. The closure runs only on a cache miss, and a thrown error is never cached.max_tokenscaps the length of the reply, which caps the cost of each call.$response->json('choices.0.message.content')uses dot notation to pull the answer out of the nested response.- Errors become one friendly
RuntimeException. Details go to your log, and users never see raw API errors.
One caveat about caching: it is safe here because the answers are general. If an answer depends on the logged-in user, add the user id to the cache key, or skip caching.
This service uses the Chat Completions endpoint. OpenAI also offers a newer Responses API. If you prefer it, only three things change: the URL, the request body and the path of the answer in the response. The rest of the design stays the same.
Step 3: Add a controller with validation
php artisan make:controller ChatController
<?php
namespace App\Http\Controllers;
use App\Services\ChatService;
use Illuminate\Http\Request;
use RuntimeException;
class ChatController extends Controller
{
public function __invoke(Request $request, ChatService $chat)
{
$data = $request->validate([
'question' => ['required', 'string', 'min:3', 'max:500'],
]);
try {
return response()->json(['answer' => $chat->ask($data['question'])]);
} catch (RuntimeException $e) {
return response()->json(['message' => $e->getMessage()], 503);
}
}
}
The 500-character limit matters: every character a user sends is something you pay for.
Step 4: Add the route and a rate limit
In routes/api.php:
use App\Http\Controllers\ChatController;
Route::post('/chat', ChatController::class)->middleware('throttle:chat');
Define the limiter in the boot method of app/Providers/AppServiceProvider.php:
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
public function boot(): void
{
RateLimiter::for('chat', function (Request $request) {
return Limit::perMinute(5)->by($request->user()?->id ?: $request->ip());
});
}
Each user (or each IP address, for visitors who are not logged in) can ask five questions per minute. Without a limit like this, one script can empty your OpenAI balance in an afternoon.
Step 5: A simple page to talk to it
This plain HTML and JavaScript works in any Blade view:
<form id="chat-form">
<input id="question" maxlength="500" required placeholder="Ask a career question">
<button type="submit">Ask</button>
</form>
<p id="answer"></p>
<script>
document.getElementById('chat-form').addEventListener('submit', async (e) => {
e.preventDefault();
const answer = document.getElementById('answer');
answer.textContent = 'Thinking...';
try {
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ question: document.getElementById('question').value }),
});
const data = await res.json();
answer.textContent = res.ok ? data.answer : (data.message ?? 'Something went wrong.');
} catch (err) {
answer.textContent = 'Network problem. Please try again.';
}
});
</script>
Notice textContent instead of innerHTML. The model's reply is text from outside your system, and putting it into the page as HTML would open the door to script injection. The Accept: application/json header makes Laravel return validation errors as JSON instead of redirecting.
Step 6: Test it without spending money
Laravel can fake HTTP calls, so your tests never reach OpenAI. Create tests/Feature/ChatTest.php:
<?php
namespace Tests\Feature;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class ChatTest extends TestCase
{
public function test_it_returns_an_answer(): void
{
config(['services.openai.key' => 'test-key', 'services.openai.model' => 'test-model']);
Http::fake([
'api.openai.com/*' => Http::response([
'choices' => [['message' => ['content' => 'Update your resume first.']]],
]),
]);
$this->postJson('/api/chat', ['question' => 'How do I start a job search?'])
->assertOk()
->assertJson(['answer' => 'Update your resume first.']);
}
public function test_it_returns_503_when_openai_fails(): void
{
config(['services.openai.key' => 'test-key', 'services.openai.model' => 'test-model']);
Http::fake(['api.openai.com/*' => Http::response([], 500)]);
$this->postJson('/api/chat', ['question' => 'Anything at all here'])
->assertStatus(503);
}
}
php artisan test
The config(...) line matters: in a test environment the key is empty, and passing an empty value to withToken would throw an error before the fake even runs.
Step 7: Try it for real
Start the server and send one request:
php artisan serve
curl -X POST http://127.0.0.1:8000/api/chat \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"question": "How should I prepare for a Laravel interview?"}'
Send the same question again: it should come back faster, because it now comes from the cache.
Cost and safety checklist
- Set a monthly spending limit in your OpenAI dashboard before you go live.
- Log usage, not just errors, so you can see which questions cost the most.
- Do not send private data. Never put passwords, ID numbers or personal documents into a prompt.
- Treat user text as untrusted. People will try to make the assistant ignore its instructions. Keep the system prompt narrow, and never put secrets in it.
- Do not trust the answer blindly. Language models can be confidently wrong. For anything important, such as legal or salary advice, show a disclaimer.
- Keep the endpoint narrow. One purpose, a short input limit and a rate limit are your best protection against abuse.
Common errors and what they mean
- 503 from our endpoint: the OpenAI call failed after retries. Check
storage/logs/laravel.logfor the real status code. - 401 in the log: the API key is missing or wrong. Check
.env, then runphp artisan config:clear. - 429 in the log: you hit a rate or quota limit on the OpenAI side. Check your usage and billing.
- 400 or 404 in the log: often a wrong or retired model name. Update
OPENAI_MODEL. - 422 from our endpoint: validation failed. The question is empty, too short or longer than 500 characters.
Conclusion
Calling an AI API takes a few lines. Making that call safe takes the rest: a hidden key, a timeout and retry, input limits, a rate limit, caching and honest error handling. With the service class and controller above, you can add the same assistant to a support page, an FAQ or an onboarding flow. Start with one narrow use, watch the logs and the bill for a week, and widen it only when you know what it costs.