Skip to main content
Laravel, thinking fast.

Chapter 1

Your First AI Endpoint

Julian Beaujardin

The most valuable thing in this chapter is how unremarkable it is.

We are going to build an endpoint that sends a prompt to a provider and returns what comes back. There is no cleverness in it. What there is instead is a boundary — one place where the provider is described, one contract the rest of the application talks to, and one set of decisions about credentials, timeouts and retries that every future AI feature inherits for free.

Get this wrong and you will pay for it in every chapter that follows. Get it right and most of the remaining chapters become small.

Start From the Wrong Place, Briefly

Here is what almost everybody writes first, and it is worth looking at honestly, because it is not stupid — it is just unfinished.

public function generate(Request $request): JsonResponse
{
    $response = Http::withToken(config('services.openai.token'))
        ->post('https://api.provider.example/v1/chat/completions', [
            'model' => 'some-model',
            'messages' => [['role' => 'user', 'content' => $request->input('prompt')]],
        ]);

    return response()->json([
        'content' => $response->json('choices.0.message.content'),
    ]);
}

This works. On a good day it works every time.

What it lacks is every decision you have not made yet. There is no timeout, so a slow provider holds this worker until PHP gives up. There is no retry, so a single 503 is a user-visible failure. There is no check that the credential exists, so an empty one is sent as an empty bearer and comes back as an authentication error that blames you for something you did not do. The provider's URL and payload shape are welded to a controller, so the day you add a second provider — or the day this one changes — you will be editing controllers.

And critically: there is nowhere to put the next decision. When you learn something about calling this provider, this shape gives you no place to record it.

The Shape That Survives

The pattern is the one Laravel already uses for every pluggable subsystem it owns — cache, queue, filesystem, mail. A contract that says what the capability is, a driver per provider that implements it, and a manager that builds drivers from configuration.

Three files.

app/Services/Ai/AiContract.php      what an AI provider can do
app/Services/Ai/AiManager.php       builds and configures drivers
app/Http/Integrations/Ai/OpenaiAPI.php   one provider

The contract comes first, and it should describe your application's needs, not the provider's API surface.

interface AiContract
{
    /**
     * Generate structured site content as a decoded array.
     *
     * @return array<string, mixed>
     */
    public function getSiteContent(string $model, string $prompt): array;

    /**
     * Generate a single block of text, capped at maxTokens.
     */
    public function getContent(string $model, string $prompt, int $maxTokens): string;
}

Notice what is not in there. There is no messages array, no response_format, no temperature. Those are one provider's vocabulary. If they leak into the contract, the contract is not an abstraction — it is a rename.

The test is simple: could you implement this interface against a completely different provider without changing a single caller? If yes, the boundary is in the right place.