After 12+ years of building PHP applications, I recently added AI-powered features to a production Laravel dashboard — automatic report summaries generated from raw analytics data. What surprised me wasn't how hard it was. It was how little good PHP-focused content exists on this topic. Almost every LLM tutorial assumes you're writing Python.
So here's the guide I wish I had: integrating the Claude API and OpenAI API into a Laravel app, with a clean architecture you can actually ship to production.
What we'll build: a ReportSummaryService that takes raw data and returns a human-readable summary — with a driver pattern so you can switch between Claude and OpenAI with one config change.
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_tokensdeliberately. 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
usageobject 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. 👇
Top comments (0)