Integrating OpenAI into Laravel with Service-First Architecture
Introduction
One of the fastest ways to add AI to a Laravel app is also one of the worst: calling the OpenAI SDK directly from controllers. It works for a demo, but it quickly becomes difficult to test, hard to swap providers, and painful to maintain.
A service-first architecture is a better production default. The controller accepts the request, a dedicated service talks to the model, and actions or jobs handle the business workflow around it.
Table of Contents
- What we want from the design
- Why controller-centric AI code breaks after the demo
- A better high-level flow
- Why prompt builders matter
- Capabilities to expect early
- Practical testing strategy
- Common mistakes and merge checklist
What We Want From the Design
The goal is simple:
- No direct SDK calls in controllers
- A clear interface that can support other providers later
- Easy fakes in unit and feature tests
- Prompt construction separated from transport concerns
A Small but Useful Structure
namespace App\Services\AI;
interface GeneratesText
{
public function generate(string $prompt, array $options = []): string;
}
namespace App\Services\AI;
use OpenAI\Laravel\Facades\OpenAI;
class OpenAITextGenerator implements GeneratesText
{
public function generate(string $prompt, array $options = []): string
{
$response = OpenAI::responses()->create([
'model' => $options['model'] ?? 'gpt-4.1-mini',
'input' => $prompt,
'temperature' => $options['temperature'] ?? 0.2,
]);
return trim($response->outputText);
}
}
namespace App\Http\Controllers;
use App\Services\AI\GeneratesText;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class SummaryController extends Controller
{
public function __invoke(Request $request, GeneratesText $generator): JsonResponse
{
$request->validate([
'content' => ['required', 'string', 'max:10000'],
]);
$summary = $generator->generate(
"Summarize this article in 3 concise bullet points:\n\n" . $request->string('content')
);
return response()->json(['summary' => $summary]);
}
}
This is not over-engineering. It is just enough structure to keep the codebase stable.
Keep Prompts Out of Transport Code
As the feature grows, prompts should stop living inside controllers or low-level provider services. Move them into dedicated prompt builders or small domain classes.
That gives you:
- prompts that can be reviewed like business rules
- cleaner versioning and experimentation
- easier reuse across controllers, jobs, and batch tasks
Why Controller-Centric AI Code Breaks Down After the Demo
Many teams start like this:
public function summarize(Request $request): JsonResponse
{
$response = OpenAI::responses()->create([
'model' => 'gpt-4.1-mini',
'input' => 'Summarize: ' . $request->string('content'),
]);
return response()->json([
'summary' => $response->outputText,
]);
}
This is not syntactically wrong. It just puts too many responsibilities in one place:
- validation
- prompt construction
- model selection
- provider communication
- response shaping
- API output decisions
That feels acceptable in a demo. It fails quickly once you need timeouts, caching, logging, fallbacks, provider swaps, or prompt versioning.
Service-First Does Not Mean Over-Engineering
A pragmatic service-first design only needs three clear boundaries:
- an interface or capability contract
- a provider-specific implementation
- a prompt builder or domain-level instruction class
That is usually enough structure to keep the feature maintainable without inventing an internal AI framework too early.
A Better High-Level Flow
HTTP request -> validation -> prompt builder -> AI service -> response normalizer -> controller response
Once the feature grows, you can add more pieces:
HTTP request -> validation -> action -> cache -> AI service -> usage logger -> response normalizer
The important thing is that the controller stays thin and the flow remains readable.
Prompt Builders Matter More Than They Look
In AI features, prompt text often becomes a business rule written in natural language. That makes it worth treating as first-class code.
namespace App\Services\AI\Prompts;
class SummaryPrompt
{
public function build(string $content): string
{
return <<<PROMPT
Summarize the following article in exactly 3 bullet points.
Keep each bullet under 24 words.
Focus on factual statements, not opinions.
Article:
{$content}
PROMPT;
}
}
Once prompts live in dedicated classes:
- they can be reviewed like business rules
- versioning becomes much easier
- you can test expected instruction structure
- experiments become cheaper to manage
Capabilities You Should Expect Early
Most production AI services quickly need:
- timeouts
- retries
- caching
- usage and latency logging
- fallback behavior when the provider is slow or unavailable
If your architecture has no place for those capabilities, they usually end up spread across controllers or middleware in ways that are hard to maintain.
A Practical Testing Strategy
You do not need to over-mock everything. A useful split is:
1. Unit test the prompt builder
Check that the prompt contains the right instructions and required input context.
2. Unit test the action or domain service
Fake GeneratesText and assert that the application uses the returned content correctly.
3. Feature test the HTTP boundary
Verify validation, status code, and response shape without depending on the real provider.
When to Introduce Provider Adapters
If the application has one provider and one small capability, GeneratesText is usually enough. If you start adding:
- multiple providers
- multiple capabilities like text, embeddings, moderation, and image generation
- runtime provider choice based on cost or latency
then an adapter or orchestrator layer starts making sense.
Common Mistakes
- putting prompts directly in controllers
- returning raw model output to clients without normalization
- skipping usage logging, so cost remains invisible
- changing instructions without prompt versioning
- depending on real network calls in tests
Merge Checklist for an AI Feature
- Is the controller still thin?
- Can prompts be reviewed independently?
- Is the output normalized before it reaches the client?
- Can the service be faked in tests?
- Are latency and token usage logged?
- Is there a clear failure path?
FAQ
Do I need a repository pattern for AI services?
Usually no. AI providers are not persistence layers. A capability interface is often a much cleaner fit.
Should the service be a singleton?
Often yes, if it is stateless. The bigger concern is keeping prompt builders and normalizers small and testable.
Key takeaways:
- Controllers should stay thin while provider calls move into dedicated services.
- Prompt builders are business rules in natural language and deserve versioning and tests.
- Service-first architecture gives you room for retries, caching, logging, and fallbacks.
- Testing works best when prompt builders, domain services, and HTTP boundaries are separated.
- Do not build a large abstraction layer before multiple providers or capabilities actually exist.
Testing Becomes Straightforward
Once everything goes through an interface, you can swap the real model client with a fake implementation in tests.
$this->app->bind(GeneratesText::class, fn () => new class implements GeneratesText {
public function generate(string $prompt, array $options = []): string
{
return '- Fast summary\n- Lower cost\n- Cleaner architecture';
}
});
Now your feature tests only need to verify validation, response shape, and application behavior.
What to Prepare Early
- token usage and latency logging
- clear timeout and retry policies
- caching for repeated prompts
- rate limiting for public endpoints
- a fallback path when the provider is slow or unavailable
If you do not plan for these early, the AI feature often becomes the most expensive and least predictable part of the system.
Conclusion
Integrating OpenAI into Laravel is not hard. Keeping the codebase clean three months later is the hard part. A service-first approach is a pragmatic default: simple enough to ship quickly, structured enough to scale without regret.