Integrating Claude/OpenAI API into a Laravel App: A Practical Guide
Step 1: Get Your API Keys
- Claude: Sign up at the Claude Console, generate a key under Account Settings.
- OpenAI: Get a key from the OpenAI Platform.
Add them to your .env:
AI_PROVIDER=claude
ANTHROPIC_API_KEY=sk-ant-xxxxx
ANTHROPIC_MODEL=claude-sonnet-4-6
OPENAI_API_KEY=sk-xxxxx
OPENAI_MODEL=gpt-5-mini
⚠️ Never hardcode API keys. Never commit them. If you've ever pushed a key to Git, rotate it immediately. (You know this. I'm saying it anyway.)
Now register them in config/services.php - this is the Laravel way, so you can use config() everywhere and benefit from config caching:
'anthropic' => [
'key' => env('ANTHROPIC_API_KEY'),
'model' => env('ANTHROPIC_MODEL', 'claude-sonnet-4-6'),
],
'openai' => [
'key' => env('OPENAI_API_KEY'),
'model' => env('OPENAI_MODEL', 'gpt-5-mini'),
],
'ai' => [
'provider' => env('AI_PROVIDER', 'claude'),
],
Step 2: Understand the Two APIs (They're 95% Similar)
Both are simple REST APIs. You POST JSON, you get JSON back.
Claude (Messages API):
POST https://api.anthropic.com/v1/messages
Headers:
x-api-key: YOUR_KEY
anthropic-version: 2023-06-01
content-type: application/json
OpenAI (Chat Completions API):
POST https://api.openai.com/v1/chat/completions
Headers:
Authorization: Bearer YOUR_KEY
content-type: application/json
The key differences that trip people up:
| Claude | OpenAI | |
|---|---|---|
| Auth header | x-api-key |
Authorization: Bearer |
| Extra header | anthropic-version required |
- |
max_tokens |
Required | Optional |
| System prompt | Top-level system field |
A message with role: system |
| Response text | content[0].text |
choices[0].message.content |
That's it. Once you know these five differences, you know both APIs.
Step 3: Build the Contract + Drivers
Instead of scattering Http::post() calls across controllers (we've all seen that codebase), let's define a contract:
<?php
// app/Services/AI/AiClientInterface.php
namespace App\Services\AI;
interface AiClientInterface
{
public function complete(
string $systemPrompt,
string $userMessage,
int $maxTokens = 1024
): string;
}
The Claude driver - Laravel's HTTP client makes this beautifully clean, no cURL boilerplate:
<?php
// app/Services/AI/ClaudeClient.php
namespace App\Services\AI;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class ClaudeClient implements AiClientInterface
{
public function complete(
string $systemPrompt,
string $userMessage,
int $maxTokens = 1024
): string {
$response = Http::withHeaders([
'x-api-key' => config('services.anthropic.key'),
'anthropic-version' => '2023-06-01',
])
->timeout(60)
->retry(2, 500, function ($exception) {
// Only retry on rate limits (429) or server errors (5xx)
return $exception->response?->status() >= 429;
})
->post('https://api.anthropic.com/v1/messages', [
'model' => config('services.anthropic.model'),
'max_tokens' => $maxTokens, // required by Claude
'system' => $systemPrompt,
'messages' => [
['role' => 'user', 'content' => $userMessage],
],
]);
if ($response->failed()) {
throw new RuntimeException(
'Claude API error: ' . $response->json('error.message', 'Unknown error')
);
}
return $response->json('content.0.text', '');
}
}
The OpenAI driver:
<?php
// app/Services/AI/OpenAiClient.php
namespace App\Services\AI;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class OpenAiClient implements AiClientInterface
{
public function complete(
string $systemPrompt,
string $userMessage,
int $maxTokens = 1024
): string {
$response = Http::withToken(config('services.openai.key'))
->timeout(60)
->retry(2, 500, function ($exception) {
return $exception->response?->status() >= 429;
})
->post('https://api.openai.com/v1/chat/completions', [
'model' => config('services.openai.model'),
'max_completion_tokens' => $maxTokens,
'messages' => [
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => $userMessage],
],
]);
if ($response->failed()) {
throw new RuntimeException(
'OpenAI API error: ' . $response->json('error.message', 'Unknown error')
);
}
return $response->json('choices.0.message.content', '');
}
}
Notice how much heavy lifting Laravel does here: timeout(), retry() with a conditional callback, withToken(), and dot-notation access into the JSON response. This is why I love this framework.
Step 4: Bind the Driver in a Service Provider
Now the magic - one config value decides which provider your entire app uses:
<?php
// app/Providers/AppServiceProvider.php
use App\Services\AI\AiClientInterface;
use App\Services\AI\ClaudeClient;
use App\Services\AI\OpenAiClient;
public function register(): void
{
$this->app->bind(AiClientInterface::class, function () {
return match (config('services.ai.provider')) {
'openai' => new OpenAiClient(),
default => new ClaudeClient(),
};
});
}
Switch providers by changing one line in .env. No code changes. If one provider has an outage or a price change, you flip a switch. This alone is worth the abstraction.
Step 5: Build a Real Feature
Here's a realistic use case from my own work: summarizing daily analytics data into a readable report for management.
<?php
// app/Services/ReportSummaryService.php
namespace App\Services;
use App\Services\AI\AiClientInterface;
class ReportSummaryService
{
public function __construct(
private AiClientInterface $ai
) {}
public function summarize(array $reportData): string
{
$systemPrompt = <<<PROMPT
You are a business analyst. Summarize the analytics data you receive into 3-4 concise sentences for a non-technical manager. Highlight the most important trend first. Do not use bullet points.
PROMPT;
return $this->ai->complete(
systemPrompt: $systemPrompt,
userMessage: 'Summarize this data: ' . json_encode($reportData),
maxTokens: 500
);
}
}
And the controller - thin, as it should be:
<?php
// app/Http/Controllers/ReportController.php
namespace App\Http\Controllers;
use App\Services\ReportSummaryService;
use Illuminate\Http\JsonResponse;
class ReportController extends Controller
{
public function summary(ReportSummaryService $summaryService): JsonResponse
{
$data = [
'total_visitors' => 12480,
'conversion_rate' => '3.2%',
'top_store' => 'Hyderabad Main Branch',
'change_vs_last_week' => '+14%',
];
return response()->json([
'summary' => $summaryService->summarize($data),
]);
}
}
Laravel's container injects everything. Your controller doesn't know or care whether Claude or OpenAI wrote the summary.
Step 6: Production Hardening (The Part Tutorials Skip)
Cache aggressively. LLM calls are slow (1–10 seconds) and cost money. If the input hasn't changed, don't call the API again:
use Illuminate\Support\Facades\Cache;
public function summarize(array $reportData): string
{
$cacheKey = 'report-summary:' . md5(json_encode($reportData));
return Cache::remember($cacheKey, now()->addHours(6), function () use ($reportData) {
return $this->ai->complete(/* ... */);
});
}
In my case this cut API costs by roughly 70% - most users request the same report multiple times a day.
Move long tasks to queues. Never make a user's HTTP request wait 10 seconds for an LLM. Dispatch a queued job, store the result, notify when ready:
GenerateReportSummary::dispatch($reportId);
If you're already running Laravel queues (Redis/database driver), this is a 15-minute change.
Handle failures gracefully. LLM APIs will fail sometimes - rate limits, overloaded servers, timeouts. The retry() calls in our drivers handle transient errors, but always wrap the feature so your app degrades gracefully:
try {
$summary = $summaryService->summarize($data);
} catch (RuntimeException $e) {
Log::warning('AI summary failed', ['error' => $e->getMessage()]);
$summary = null; // UI shows the raw data table instead
}
The AI feature should be an enhancement, not a single point of failure.
Control your costs. Set max_tokens deliberately. It caps your output cost per request. Use smaller models for simple tasks. Summaries, classification, and extraction rarely need the flagship models - Claude Haiku or a mini-tier OpenAI model is often 10x cheaper and plenty good. Set spend limits in both provider dashboards before you ship. Trust me on this one. Log token usage. Both APIs return a usage object in the response - store it, so you know exactly what each feature costs.
What About the Official SDKs?
Everything above uses Laravel's HTTP client so you can see exactly what's happening on the wire. For bigger projects, consider:
- Anthropic's official PHP SDK:
composer require anthropic-ai/sdk guzzlehttp/guzzle(requires PHP 8.1+) - handles streaming, pagination, and retries for you - openai-php/laravel: a popular community package with a Laravel-native facade
I'd still recommend writing the raw version once, like we did here. Understanding the actual API makes debugging SDK issues far easier later.
Wrapping Up
The full picture:
- Keys in
.env, referenced viaconfig/services.php - An
AiClientInterfacecontract with a driver per provider - Container binding so one env variable switches providers
- Thin controllers, logic in services
- Cache + queues + graceful failure for production
None of this is exotic - it's the same clean Laravel architecture you already use, applied to a new kind of API. That's the real takeaway: you don't need to learn Python to build AI features. Your PHP skills transfer directly.
In an upcoming post, I'll cover streaming responses to the browser (so summaries appear word-by-word, ChatGPT-style) using Laravel and Server-Sent Events. Follow me if you'd like to catch that one.
Have you added AI features to a PHP app? What did you use - raw HTTP, an SDK, or something else? I'd love to hear about it in the comments. 👇
Comments
No comments yet. Start the discussion.