Adding AI to Your Laravel App: Practical Integration, Not a Demo
I've shipped AI features in three production applications over the past year. Not chatbots — actual features. Summarising transcripts, classifying content, extracting structured data from messy inputs
Adding AI to Your Laravel App: Practical Integration, Not a Demo
I've shipped AI features in three production applications over the past year. Not chatbots — actual features. Summarising transcripts, classifying content, extracting structured data from messy inputs, generating developer activity reports. The thing nobody tells you about integrating AI into a real app is that the API call is the easy bit. Everything around it is where it gets interesting.
The Action Pattern for AI Calls
If you've read anything else on this site, you know I'm an Actions person. AI calls are no different. Here's the pattern I use everywhere:
class SummariseTranscript
{
public function __construct(
private OpenAIService $openai,
private TranscriptRepository $transcripts,
) {}
public function execute(Transcript $transcript): TranscriptSummary
{
$cached = Cache::get("transcript_summary_{$transcript->id}");
if ($cached) {
return $cached;
}
$response = $this->openai->chat([
'model' => 'gpt-4o',
'messages' => [
['role' => 'system', 'content' => $this->buildSystemPrompt()],
['role' => 'user', 'content' => $transcript->content],
],
'response_format' => ['type' => 'json_object'],
]);
$summary = TranscriptSummary::fromAIResponse(
$transcript,
json_decode($response->choices[0]->message->content, true)
);
Cache::put("transcript_summary_{$transcript->id}", $summary, now()->addDays(7));
return $summary;
}
}
Nothing revolutionary. But notice what this gives you: testability (mock the OpenAIService), caching (don't re-summarise the same transcript), and a clean return type (a DTO, not a raw API response). Every AI integration I build follows this shape.
The Service Wrapper
I don't call the OpenAI or Anthropic APIs directly from Actions. There's always a service class in between:
class OpenAIService
{
private Client $client;
public function __construct()
{
$this->client = OpenAI::client(config('services.openai.key'));
}
public function chat(array $params): CreateResponse
{
try {
return retry(3, fn () => $this->client->chat()->create($params), 1000);
} catch (Throwable $e) {
Log::error('OpenAI API call failed', [
'error' => $e->getMessage(),
'model' => $params['model'] ?? 'unknown',
]);
throw new AIServiceUnavailableException(
'OpenAI is currently unavailable',
previous: $e
);
}
}
}
The retry(3, ..., 1000) is doing the heavy lifting here. AI APIs have transient failures all the time — rate limits, timeouts, random 500s. Three retries with a one-second delay between them handles 90% of issues without your users ever noticing.
The custom exception matters too. Your controller or queue job can catch AIServiceUnavailableException specifically and handle it — show a "try again later" message, schedule a retry job, whatever makes sense. Don't let raw HTTP exceptions bubble up to your error pages.
Queue Everything You Can
Here's the thing about AI API calls: they're slow. A GPT-4o call might take 5-15 seconds. Claude can be similar. You absolutely cannot do this in a web request if you care about user experience.
class ProcessTranscriptJob implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public int $backoff = 60;
public function __construct(
private Transcript $transcript,
) {}
public function handle(SummariseTranscript $summarise): void
{
$summary = $summarise->execute($this->transcript);
$this->transcript->update([
'summary' => $summary->text,
'key_points' => $summary->keyPoints,
'status' => TranscriptStatus::Processed,
]);
TranscriptProcessed::dispatch($this->transcript);
}
public function failed(Throwable $exception): void
{
$this->transcript->update(['status' => TranscriptStatus::Failed]);
Log::error('Transcript processing failed permanently', [
'transcript_id' => $this->transcript->id,
'error' => $exception->getMessage(),
]);
}
}
The pattern: user uploads something, you immediately respond with "processing", dispatch a job, and either poll or use WebSockets to notify when it's done. On Progress, I use Reverb for real-time updates. On simpler projects, polling every few seconds works fine.
The $backoff = 60 is deliberate. If the API is having issues, hammering it every second makes things worse. Wait a minute, try again.
Caching Is Cost Control
AI API calls cost money. Not a lot per call, but it adds up fast if you're not careful. My caching strategy is straightforward:
- Deterministic inputs get cached aggressively. Same transcript? Same summary. Cache it for a week.
- User-facing queries get cached briefly. Same search query in the last hour? Return the cached result.
- Background processing doesn't need caching. If it's a queue job running once, just run it.
// In your Action
$cacheKey = 'ai_summary_' . md5($content);
$cacheDuration = now()->addDays(7);
return Cache::remember($cacheKey, $cacheDuration, function () use ($content) {
return $this->openai->chat([...]);
});
On DevTrends, where we're processing hundreds of sources daily, caching cut our API costs by about 60%. That's not optimisation for fun — that's the difference between a viable product and an expensive hobby.
Structured Output Parsing
The biggest pain point with AI integration isn't the API call — it's parsing the response. AI models return text. You need structured data. Here's what actually works:
class ParseAIResponse
{
public function execute(string $jsonString, string $dtoClass): mixed
{
$data = json_decode($jsonString, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new AIResponseParsingException(
"Failed to parse AI response as JSON: " . json_last_error_msg()
);
}
// Validate required fields exist
$required = $dtoClass::requiredFields();
$missing = array_diff($required, array_keys($data));
if (!empty($missing)) {
throw new AIResponseParsingException(
"AI response missing required fields: " . implode(', ', $missing)
);
}
return $dtoClass::fromArray($data);
}
}
Always ask for JSON output. Always validate the structure before trying to use it. AI models will occasionally return malformed JSON, miss fields, or add fields you didn't ask for. Defensive parsing isn't paranoia — it's experience.
When Claude vs When OpenAI
I use both. Here's my rough decision tree:
- Structured data extraction: OpenAI with JSON mode. It's more reliable at following schemas.
- Long content analysis: Claude. Better context window handling, tends to be more thorough.
- Code generation/analysis: Claude. This is where Anthropic genuinely excels.
- Quick classifications: OpenAI GPT-4o-mini. Cheap, fast, good enough.
Don't marry a single provider. Wrap your service classes so you can swap implementations. I've switched providers mid-project twice now when one had extended outages or pricing changes.
The Pattern That Ties It Together
Every AI feature I build follows the same flow:
- Controller receives the request, validates input, dispatches a job (or calls an Action for sync operations)
- Queue Job calls the Action, handles retries and failure states
- Action contains the business logic — prompt building, API calling, response parsing
- Service wraps the raw API client with retry logic and error handling
- DTO gives you a typed, validated response to work with
- Cache sits in front of the Action to avoid redundant calls
It's just Laravel. Actions, Jobs, Services, DTOs. The AI bit is one API call inside all of that. The rest is engineering — the same engineering you'd apply to any external API integration.
That's the thing people miss when they think about "adding AI" to their app. It's not a paradigm shift. It's an API call with extra steps. The patterns you already know — queue jobs, caching, error handling, retry logic — those are exactly what you need. You just need to apply them deliberately.
Stop building chatbot demos. Start building features.
I write about Laravel, AI tooling, and the realities of building software. More at stuartmason.co.uk.
Get the Friday email
What I shipped this week, what I learned, one useful thing.
No spam. Unsubscribe anytime. Privacy policy.