Integrating OpenAI into Laravel with Service-First Architecture

· 7 min read

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:

  1. an interface or capability contract
  2. a provider-specific implementation
  3. 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:

  1. Controllers should stay thin while provider calls move into dedicated services.
  2. Prompt builders are business rules in natural language and deserve versioning and tests.
  3. Service-first architecture gives you room for retries, caching, logging, and fallbacks.
  4. Testing works best when prompt builders, domain services, and HTTP boundaries are separated.
  5. 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.

Comments