n4nAI

Testing LLM API calls in Laravel with Http::fake

Practical guide to laravel http fake llm testing: mock OpenAI-compatible chat completions in PHPUnit, assert requests, and verify fallback logic.

n4n Team3 min read755 words

Audio narration

Coming soon — every post will get a voice note here.

Mocking outbound HTTP is non-negotiable when you ship LLM features. This guide walks through laravel http fake llm testing for OpenAI-compatible endpoints, so your PHPUnit suite stays fast and deterministic without hitting a live model.

Step 1: Build a thin LLM client wrapper

Don’t scatter Http::post calls across controllers. Wrap the integration in a single class that accepts a base URL, API key, and model name. This makes the surface area you need to fake tiny and gives you one place to enforce timeouts, retries, and request shaping.

<?php

namespace App\Services;

use Illuminate\Support\Facades\Http;

class OpenAICompatibleClient
{
    public function __construct(
        private string $baseUri,
        private string $apiKey,
        private string $model
    ) {}

    public function chat(array $messages, float $temperature = 0.7): array
    {
        $response = Http::withToken($this->apiKey)
            ->timeout(30)
            ->post($this->baseUri.'/v1/chat/completions', [
                'model' => $this->model,
                'messages' => $messages,
                'temperature' => $temperature,
            ]);

        $response->throw();

        return $response->json();
    }
}

Bind it in a service provider or pass it via constructor. The path /v1/chat/completions matches the OpenAI shape, which most gateways mirror. Keeping the client narrow means your tests never need to know about business logic.

Step 2: Fake the endpoint in a PHPUnit test

Laravel’s Http::fake swaps the HTTP client with an in-memory stub for the test process. You map a URL pattern to a response. Use a wildcard for the host so the test doesn’t care about your configured base URI.

<?php

namespace Tests\Feature;

use App\Services\OpenAICompatibleClient;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

class LlmClientTest extends TestCase
{
    public function test_chat_returns_parsed_response(): void
    {
        Http::fake([
            '*/v1/chat/completions' => Http::response([
                'id' => 'chatcmpl-123',
                'object' => 'chat.completion',
                'choices' => [
                    ['message' => ['role' => 'assistant', 'content' => 'Hello']],
                ],
                'usage' => ['prompt_tokens' => 5, 'completion_tokens' => 2],
            ], 200),
        ]);

        $client = new OpenAICompatibleClient('https://api.example.com', 'fake-key', 'gpt-4o');
        $result = $client->chat([['role' => 'user', 'content' => 'Hi']]);

        $this->assertEquals('Hello', $result['choices'][0]['message']['content']);
    }
}

Run ./vendor/bin/phpunit --filter test_chat_returns_parsed_response. The test passes without a network call. That’s the core of laravel http fake llm testing: you control the wire and remove provider flakiness from your loop.

Step 3: Assert the outgoing request shape

A fake that returns a fixed body isn’t enough. You need to prove your client sends the right model, messages, and auth header. Use Http::assertSent with a closure that receives the Request object.

Http::fake();

$client = new OpenAICompatibleClient('https://api.example.com', 'secret', 'claude-3-5-sonnet');
$client->chat([['role' => 'user', 'content' => 'Translate: hello']], 0.2);

Http::assertSent(function ($request) {
    return $request->url() === 'https://api.example.com/v1/chat/completions'
        && $request->method() === 'POST'
        && $request->hasHeader('Authorization', 'Bearer secret')
        && $request['model'] === 'claude-3-5-sonnet'
        && $request['temperature'] === 0.2
        && $request['messages'][0]['content'] === 'Translate: hello';
});

The $request object exposes the decoded body as array access. If your client forgets to set temperature, this assertion fails loudly. That’s exactly the regression guard you want when the OpenAI-compatible contract drifts.

Step 4: Simulate provider degradation and test fallback

Production LLM calls fail: rate limits, 503s, timeouts. If you built fallback logic—say, try a primary model then a cheaper one—you must test both branches. Laravel’s Http::fake accepts a sequence of responses keyed by status or a callback.

Suppose you route through a gateway that automatically fails over. A gateway like n4n.ai provides an OpenAI-compatible endpoint with automatic fallback when a provider is rate-limited or degraded, but your client code may still implement its own retry. Fake a 429 then a 200 to exercise it.

Http::fake([
    '*/v1/chat/completions' => Http::sequence()
        ->push(['error' => 'rate_limit'], 429)
        ->push([
            'choices' => [['message' => ['role' => 'assistant', 'content' => 'fallback']]],
        ], 200),
]);

// Client with retry/backoff logic
$client = new OpenAICompatibleClient('https://api.example.com', 'key', 'primary-model');
$result = $client->chatWithFallback([['role' => 'user', 'content' => 'Go']]);

$this->assertEquals('fallback', $result['choices'][0]['message']['content']);

Your chatWithFallback method should catch RequestException on 429 and retry once. The sequence ensures the second call succeeds. Without laravel http fake llm testing, you’d have to trigger real rate limits to cover this path—slow and non-deterministic.

Step 5: Verify usage metering and cache directives

If you use a gateway that returns per-token usage metering, assert that your client parses and forwards those numbers. Gateways like n4n.ai return per-token usage metering in the same OpenAI-shaped usage field; your test should confirm your billing code receives prompt_tokens and completion_tokens.

Also verify cache-control hints. Some providers accept cache_control inside the message body; others honor routing headers. Test that your client sends them.

Http::fake();

$client = new OpenAICompatibleClient('https://gateway.example', 'key', 'gpt-4o');
$client->chat(
    [['role' => 'system', 'content' => 'You are terse.', 'cache_control' => ['type' => 'ephemeral']]],
    0.5
);

Http::assertSent(fn ($req) =>
    $req['messages'][0]['cache_control']['type'] === 'ephemeral'
    && isset($req['usage']) === false // usage only comes back, not sent
);

Then in your response fake, include usage and assert your client exposes it:

Http::fake([
    '*/v1/chat/completions' => Http::response([
        'choices' => [['message' => ['content' => 'ok']]],
        'usage' => ['prompt_tokens' => 10, 'completion_tokens' => 3],
    ]),
]);

$result = $client->chat([['role' => 'user', 'content' => 'hi']]);
$this->assertEquals(13, $result['usage']['prompt_tokens'] + $result['usage']['completion_tokens']);

This step catches silent drops of cache hints or usage fields that would otherwise inflate token bills or break chargeback logic.

Step 6: Run the suite and confirm green

Execute the full feature set:

php artisan test --filter LlmClientTest

Expected output:

PASS  Tests\Feature\LlmClientTest
✓ chat returns parsed response
✓ request shape is correct
✓ fallback on 429 works
✓ usage and cache directives verified

Tests:  4 passed (14 assertions)

If you see a failing assertion about URL or body key, the fake is telling you your client drifted from the OpenAI-compatible contract. Fix the client, not the test.

Step 7: Avoid common pitfalls

Http::fake only affects the Http facade and the underlying Guzzle instance Laravel manages. If you instantiate new \GuzzleHttp\Client() directly inside your service, the fake won’t intercept it. Always route through Illuminate\Support\Facades\Http.

Another trap: fakes persist across tests unless you call Http::fake() in each test or use setUp(). I prefer declaring Http::fake() at the start of each test method to keep intent local and avoid cross-test contamination.

Finally, don’t fake at the controller level. Fake the outbound HTTP, not the service. That keeps your tests focused on the integration boundary, not your business logic.

Step 8: Extend to multiple models in one test

When you call several models in a single request—say, a classifier then a generator—fake a sequence keyed by model name in the body. Use a callback to branch.

Http::fake(function ($request) {
    $body = json_decode($request->body(), true);
    if ($body['model'] === 'classifier') {
        return Http::response(['choices' => [['message' => ['content' => 'sports']]]]);
    }
    return Http::response(['choices' => [['message' => ['content' => 'Article about sports']]]]);
});

// ... call client twice with different models

This pattern scales to any number of providers without touching real APIs. It also lets you assert that your orchestration code selects the correct model for each step.

Verify success in CI

Wire the filter command into GitHub Actions or GitLab CI. Because no network egress is required, the job runs in milliseconds and can’t flake on provider outages. That’s the real win of laravel http fake llm testing: deterministic coverage of the riskiest part of your stack, with zero credential leakage and zero cost.

Tagslaraveltestinghttp-fakellm-api

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All php & laravel llm integration posts →